Kept the unsent message with its chat (#1049)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-11 15:52:09 +02:00
1 parent 1c13300988
commit 468d28aa94
18 files changed
+1333 -240

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
using System.Text.Json.Serialization;
namespace AIStudio.Chat;
/// <summary>
/// The message of a chat which the user has not sent yet, with its text and its attachments.
/// </summary>
/// <remarks>
/// The draft is stored with its chat, so the message is still there when the user comes back to the
/// chat.<br/><br/>
///
/// A managed transcript always belongs to exactly one owner: to a sent message, or to the draft.
/// Sending hands the attachments of the draft to the new message, and editing a message is the way
/// back. While a transcript belongs to the draft, removing it from the composer deletes its file, and
/// opening the chat again brings it back into the composer.<br/><br/>
///
/// The draft is a class rather than a record on purpose. A copy of its thread made with a with
/// expression is still the same chat, e.g., after the user picked another profile, so both copies have
/// to share one draft. Several parties change the draft at the same time, too: the user in the
/// composer, and the media transcription service, which adds a finished transcript from the thread
/// pool. Meanwhile, a running AI job may store the thread. That is why every change replaces the text
/// or the list of attachments under a lock instead of changing the list in place: whoever reads the
/// draft, the serializer included, always gets a complete list.
/// </remarks>
public sealed class ChatDraft
{
private readonly Lock stateLock = new();
/// <summary>
/// The text of the message.
/// </summary>
[JsonInclude]
public string Text { get; private set; } = string.Empty;
/// <summary>
/// The files attached to the message, the transcripts of audio and video files included.
/// </summary>
/// <remarks>
/// Declared with the base type, so every attachment is stored together with its kind, and a
/// transcript is still a transcript after the chat was loaded again.
/// </remarks>
[JsonInclude]
public IReadOnlyList<FileAttachment> FileAttachments { get; private set; } = [];
/// <summary>
/// Whether the draft holds neither text nor attachments.
/// </summary>
[JsonIgnore]
public bool IsEmpty => string.IsNullOrWhiteSpace(this.Text) && this.FileAttachments.Count == 0;
/// <summary>
/// The transcripts among the attachments, whose files the chat manages itself.
/// </summary>
[JsonIgnore]
public IReadOnlyList<ManagedTranscriptAttachment> MediaTranscripts => this.FileAttachments.OfType<ManagedTranscriptAttachment>().ToList();
/// <summary>
/// Replaces the text of the message.
/// </summary>
/// <param name="text">The text as the user wrote it.</param>
public void SetText(string? text)
{
lock (this.stateLock)
this.Text = text ?? string.Empty;
}
/// <summary>
/// Takes over the attachments the composer shows.
/// </summary>
/// <remarks>
/// Transcripts are only ever added here, never removed. The media transcription service hands a
/// finished transcript to the draft before the composer shows it, so a draft which simply took over
/// what the composer shows would lose such a transcript, and its file would stay behind without an
/// owner. A transcript leaves the draft when the user removes it, see RemoveAttachments.
/// </remarks>
/// <param name="composerAttachments">The attachments the composer shows.</param>
public void MirrorAttachments(IEnumerable<FileAttachment> composerAttachments)
{
lock (this.stateLock)
this.FileAttachments = Combine(composerAttachments, this.MediaTranscripts);
}
/// <summary>
/// Adds attachments the draft does not hold yet.
/// </summary>
/// <param name="attachments">The attachments to add. One whose path the draft already holds is left out.</param>
/// <returns>True when at least one attachment was added.</returns>
public bool AddAttachments(IEnumerable<FileAttachment> attachments)
{
lock (this.stateLock)
{
var combined = Combine(this.FileAttachments, attachments);
if (combined.Count == this.FileAttachments.Count)
return false;
this.FileAttachments = combined;
return true;
}
}
/// <summary>
/// Removes attachments, which is what the user does by taking them out of the composer.
/// </summary>
/// <param name="attachments">The attachments to remove. They are found by their path, so a newer state of the same file is removed as well.</param>
public void RemoveAttachments(IEnumerable<FileAttachment> attachments)
{
var removedPaths = attachments.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
lock (this.stateLock)
this.FileAttachments = this.FileAttachments.Where(attachment => !removedPaths.Contains(attachment.FilePath)).ToList();
}
/// <summary>
/// Replaces every attachment with what the given function makes of it.
/// </summary>
/// <remarks>
/// Meant for files which changed their place, e.g., because the chat moved into another workspace
/// or was copied, together with its transcripts.
/// </remarks>
/// <param name="replace">Gives the attachment to keep in place of the given one, or the given one itself when nothing changes.</param>
public void ReplaceAttachments(Func<FileAttachment, FileAttachment> replace)
{
lock (this.stateLock)
this.FileAttachments = this.FileAttachments.Select(replace).ToList();
}
/// <summary>
/// Makes a sent message the draft again, which is what editing a message does.
/// </summary>
/// <remarks>
/// Text and attachments of the message replace what the draft held so far, except for its
/// transcripts: the draft is their only owner, so dropping them would leave their files behind.
/// </remarks>
/// <param name="message">The message to edit.</param>
public void RestoreFromMessage(ContentText message)
{
lock (this.stateLock)
{
this.Text = message.Text;
this.FileAttachments = Combine(message.FileAttachments, this.MediaTranscripts);
}
}
/// <summary>
/// Empties the draft, which is what sending it does: its attachments belong to the new message from then on.
/// </summary>
public void Clear()
{
lock (this.stateLock)
{
this.Text = string.Empty;
this.FileAttachments = [];
}
}
/// <summary>
/// Puts two lists of attachments together, keeping the first attachment of every path.
/// </summary>
private static List<FileAttachment> Combine(IEnumerable<FileAttachment> first, IEnumerable<FileAttachment> second)
{
var paths = new HashSet<string>(StringComparer.Ordinal);
return first.Concat(second).Where(attachment => paths.Add(attachment.FilePath)).ToList();
}
}
@@ -0,0 +1,195 @@
namespace AIStudio.Chat;
/// <summary>
/// Stores a chat shortly after the user changed its draft, and once more when the user leaves it.
/// </summary>
/// <remarks>
/// The message the user has not sent yet travels in the draft of its chat, so storing the chat is what
/// keeps the message. Storing after every keystroke would write the whole conversation each time,
/// which is why this waits until the user paused for a moment. Every change starts the waiting anew,
/// by moving the one timer instead of starting another one.<br/><br/>
///
/// Only a chat which is stored already gets stored here. Putting a new chat on the disk is up to whoever
/// created it, and a chat which is gone from the disk was deleted: storing it would bring it back.<br/><br/>
///
/// While an answer is being written for the chat, nothing is stored here either. The AI job changes the
/// conversation and stores it on its own, with the draft, since it holds the very same draft. Storing
/// the chat from here at the same time could read the conversation in the middle of a change.<br/><br/>
///
/// Storing always runs on the thread pool, never on the renderer, because it writes the whole
/// conversation to the disk.
/// </remarks>
/// <param name="delay">How long the user has to pause before the chat is stored.</param>
/// <param name="isStored">Whether the chat is stored on the disk.</param>
/// <param name="isBusy">Whether an answer is being written for the chat right now.</param>
/// <param name="storeAsync">Stores the chat, and answers false when that was skipped.</param>
/// <param name="logger">Where failures are reported.</param>
public sealed class ChatDraftAutosave(TimeSpan delay, Func<ChatThread, bool> isStored, Func<ChatThread, bool> isBusy, Func<ChatThread, Task<bool>> storeAsync, ILogger logger) : IAsyncDisposable
{
private readonly Lock stateLock = new();
private Timer? timer;
private ChatThread? pendingChat;
private bool isDisposed;
/// <summary>
/// Notes that the user changed the draft of the given chat, which gets stored once the user paused.
/// </summary>
/// <param name="chat">The chat as the composer shows it now.</param>
public void Schedule(ChatThread chat)
{
ChatThread? previousChat;
lock (this.stateLock)
{
if (this.isDisposed)
return;
previousChat = this.pendingChat is { } pending && pending.ChatId != chat.ChatId ? pending : null;
this.pendingChat = chat;
this.timer ??= new Timer(this.WhenDelayElapsed);
this.timer.Change(delay, Timeout.InfiniteTimeSpan);
}
//
// Whoever opens another chat flushes before, so this is a safety net only: a change in one chat
// must not cost another chat its draft.
//
if (previousChat is not null)
this.StoreWhenLeavingAsync(previousChat).Observe($"{nameof(ChatDraftAutosave)}: storing the draft of a chat left without a flush");
}
/// <summary>
/// Swaps the noted chat for a newer copy of the same chat.
/// </summary>
/// <remarks>
/// Some changes replace the thread with a copy, e.g., picking another profile. The copy shares the
/// draft, but storing the former thread would bring the former profile back.
/// </remarks>
/// <param name="chat">The copy which replaced the thread.</param>
public void Follow(ChatThread chat)
{
lock (this.stateLock)
{
if (this.pendingChat?.ChatId == chat.ChatId)
this.pendingChat = chat;
}
}
/// <summary>
/// Stores the noted chat right away, which is what leaving the chat calls for.
/// </summary>
public async Task FlushAsync()
{
ChatThread? chat;
lock (this.stateLock)
{
this.timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
chat = this.pendingChat;
this.pendingChat = null;
}
if (chat is not null)
await this.StoreWhenLeavingAsync(chat);
}
private Task StoreWhenLeavingAsync(ChatThread chat) => Task.Run(async () =>
{
//
// A chat being answered is left to the AI job, which stores it once more when the answer is
// done, and the draft with it:
//
if (isBusy(chat) || !isStored(chat))
return;
await this.TryStoreAsync(chat);
});
private void WhenDelayElapsed(object? state) => this.StoreAfterPauseAsync().Observe($"{nameof(ChatDraftAutosave)}: storing a draft after a pause");
private async Task StoreAfterPauseAsync()
{
ChatThread? chat;
lock (this.stateLock)
chat = this.isDisposed ? null : this.pendingChat;
if (chat is null)
return;
if (isBusy(chat))
{
lock (this.stateLock)
{
if (!this.isDisposed && ReferenceEquals(this.pendingChat, chat))
this.timer?.Change(delay, Timeout.InfiniteTimeSpan);
}
return;
}
if (!isStored(chat))
return;
lock (this.stateLock)
{
// Another change came in meanwhile, and its own timer runs already:
if (!ReferenceEquals(this.pendingChat, chat))
return;
this.pendingChat = null;
}
if (await this.TryStoreAsync(chat))
return;
//
// Noted again for the next change or for leaving the chat, but without a timer: when the disk
// fails, trying again every few seconds would only fill the log.
//
lock (this.stateLock)
{
if (!this.isDisposed)
this.pendingChat ??= chat;
}
}
private async Task<bool> TryStoreAsync(ChatThread chat)
{
try
{
if (await storeAsync(chat))
return true;
logger.LogWarning("Storing the draft of the chat '{ChatId}' was skipped, because another operation held the chat.", chat.ChatId);
}
catch (Exception e)
{
logger.LogError(e, "The draft of the chat '{ChatId}' could not be stored.", chat.ChatId);
}
return false;
}
#region Implementation of IAsyncDisposable
/// <summary>
/// Stores what is still noted, and stops the timer.
/// </summary>
public async ValueTask DisposeAsync()
{
lock (this.stateLock)
this.isDisposed = true;
await this.FlushAsync();
Timer? disposedTimer;
lock (this.stateLock)
{
disposedTimer = this.timer;
this.timer = null;
}
if (disposedTimer is not null)
await disposedTimer.DisposeAsync();
}
#endregion
}
+25 -14
View File
@@ -34,10 +34,27 @@ public sealed record ChatThread
public ulong LastMediaTranscriptNumber { get; set; }
/// <summary>
/// Managed transcript attachments prepared for the composer but not sent yet.
/// Empty by default so older serialized threads require no migration.
/// The message the user is writing in this chat and has not sent yet.
/// </summary>
public List<ManagedTranscriptAttachment> PendingMediaTranscripts { get; set; } = [];
/// <remarks>
/// Every copy of this thread made with a with expression shares the draft, because such a copy is
/// still the same chat. A copy which becomes another chat needs a draft of its own.
/// </remarks>
public ChatDraft Draft { get; init; } = new();
// Compatibility shim: until v26.9.1, a chat stored only the transcripts of its draft, in a list of their own. See documentation/compatibility-shims/2026-10-chat-pending-media-transcripts.md; remove after 2027-04-11.
[JsonInclude]
[JsonPropertyName("pending_media_transcripts")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
private List<ManagedTranscriptAttachment>? LegacyPendingMediaTranscripts
{
get => null;
init
{
if (value is not null)
this.Draft.AddAttachments(value);
}
}
/// <summary>
/// Specifies the provider selected for the chat thread.
@@ -478,10 +495,10 @@ public sealed record ChatThread
/// would have to transcribe the media all over again.<br/><br/>
///
/// A transcript always belongs to exactly one owner: to a sent message, or to the draft in the
/// composer, which this thread keeps in PendingMediaTranscripts. Sending hands the transcripts of
/// the draft to the new message; editing is the way back. From then on, the rules of the draft
/// apply: removing the attachment from the composer deletes its file, and opening the chat again
/// brings it back into the composer.
/// composer, which this thread keeps in Draft. Sending hands the attachments of the draft to the
/// new message; editing is the way back, so the message becomes the draft again. From then on, the
/// rules of the draft apply: removing the attachment from the composer deletes its file, and
/// opening the chat again brings it back into the composer.
/// </remarks>
/// <param name="content">The content of the user message to edit.</param>
public void RemoveForEditing(IContent content)
@@ -491,13 +508,7 @@ public sealed record ChatThread
return;
if (block.Content is ContentText textContent)
{
foreach (var transcript in textContent.FileAttachments.OfType<ManagedTranscriptAttachment>())
{
if (this.PendingMediaTranscripts.All(existing => existing.FilePath != transcript.FilePath))
this.PendingMediaTranscripts.Add(transcript);
}
}
this.Draft.RestoreFromMessage(textContent);
this.Blocks.Remove(block);
}
@@ -178,25 +178,13 @@ public partial class AttachDocuments : MSGComponentBase
var completed = this.EffectiveImportOwner.Kind.PersistsOwnSources()
? Array.Empty<FileAttachment>()
: delivery?.Attachments ?? [];
var pending = this.OwnerChat?.PendingMediaTranscripts ?? [];
var pending = this.OwnerChat?.Draft.MediaTranscripts ?? [];
var changed = false;
var ownerPendingChanged = false;
foreach (var attachment in completed.Concat(pending))
changed |= this.DocumentPaths.Add(attachment);
if (this.OwnerChat is not null)
{
foreach (var attachment in completed.OfType<ManagedTranscriptAttachment>())
{
if (this.OwnerChat.PendingMediaTranscripts.All(existing => existing.FilePath != attachment.FilePath))
{
this.OwnerChat.PendingMediaTranscripts.Add(attachment);
ownerPendingChanged = true;
}
}
}
var ownerPendingChanged = this.OwnerChat?.Draft.AddAttachments(completed.OfType<ManagedTranscriptAttachment>()) ?? false;
if (changed || ownerPendingChanged)
{
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
@@ -276,10 +264,11 @@ public partial class AttachDocuments : MSGComponentBase
var previousAttachments = this.DocumentPaths.ToHashSet();
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable);
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
var removedAttachments = previousAttachments.Except(this.DocumentPaths).ToList();
foreach (var removedAttachment in removedAttachments)
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft(removedAttachments);
//
// Said out loud, like every other path in this file. Removing a file in the dialog changed
@@ -295,11 +284,12 @@ public partial class AttachDocuments : MSGComponentBase
if (this.IsUnavailable)
return;
foreach (var attachment in this.DocumentPaths)
var removedAttachments = this.DocumentPaths.ToList();
foreach (var attachment in removedAttachments)
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
this.DocumentPaths.Clear();
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft(removedAttachments);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
@@ -311,21 +301,22 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths.Remove(fileAttachment);
ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment);
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft([fileAttachment]);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
private void ReconcileOwnerPendingTranscripts()
{
if (this.OwnerChat is null)
return;
var retainedPaths = this.DocumentPaths.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
}
/// <summary>
/// Removes the attachments the user took out of the composer from the draft of the owner chat.
/// </summary>
/// <remarks>
/// Only what the user removed leaves the draft. The media transcription service hands a finished
/// transcript to the draft before the composer shows it, so aligning the draft with the composer
/// would drop such a transcript, and its file would stay behind without an owner.
/// </remarks>
/// <param name="removedAttachments">The attachments the user removed.</param>
private void RemoveFromOwnerDraft(IEnumerable<FileAttachment> removedAttachments) => this.OwnerChat?.Draft.RemoveAttachments(removedAttachments);
/// <summary>
/// Validates the given paths and attaches every file which passes.
@@ -38,7 +38,7 @@
<FooterContent>
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
<MudElement Style="flex: 0 0 auto;">
<UserPromptComponent
<MudTextField
T="string"
@ref="@this.inputField"
@bind-Text="@this.UserInput"
@@ -56,7 +56,6 @@
OnKeyUp="@this.InputKeyEvent"
UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass"
DebounceTime="TimeSpan.FromSeconds(1)"
HelperText="@this.TokenCountMessage"
Style="@this.UserInputStyle"/>
</MudElement>
@@ -11,6 +11,7 @@ using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@@ -69,6 +70,9 @@ public partial class ChatComponent : MSGComponentBase
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
[Inject]
private NavigationManager NavigationManager { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@@ -94,6 +98,7 @@ public partial class ChatComponent : MSGComponentBase
private Guid currentChatThreadId = Guid.Empty;
private Guid loadedParameterChatId = Guid.Empty;
private Guid loadedParameterWorkspaceId = Guid.Empty;
private bool hasAppliedChatParameter;
private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion;
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
@@ -139,6 +144,35 @@ public partial class ChatComponent : MSGComponentBase
/// </summary>
private ConversationTokenTracker? tokenTracker;
/// <summary>
/// How long the user has to pause before the open chat is stored with the message being written.
/// </summary>
private static readonly TimeSpan DRAFT_AUTOSAVE_DELAY = TimeSpan.FromSeconds(2);
/// <summary>
/// Stores the open chat with the message being written, while chats are stored automatically.
/// </summary>
private ChatDraftAutosave? draftAutosave;
/// <summary>
/// A new chat which has not reached the disk yet, or null.
/// </summary>
/// <remarks>
/// Starting a new chat in a workspace creates its thread right away, so that it keeps the
/// workspace, but stores it only together with its first message. Until then, leaving the chat
/// stores it when the user started a message in it, see PreserveNewChatDraftAsync. Which chat
/// that is has to be noted here: once the chat is left, the component may show the next one
/// already. Nor does the disk tell, since a deleted chat is missing there as well, and storing
/// that one would bring it back. Hence, this is always the chat on the screen, and it is
/// forgotten as soon as the chat was stored or left.
/// </remarks>
private ChatThread? unstoredChat;
/// <summary>
/// Lets this component store a new chat before the user leaves the chat page.
/// </summary>
private IDisposable? locationChangingRegistration;
/// <summary>
/// How long to leave the token count alone after it ran.
/// </summary>
@@ -228,7 +262,7 @@ public partial class ChatComponent : MSGComponentBase
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
private UserPromptComponent<string> inputField = null!;
private MudTextField<string> inputField = null!;
/// <summary>
/// Represents the user's input in the chat interface.
@@ -237,12 +271,20 @@ public partial class ChatComponent : MSGComponentBase
/// This property serves as a bridge between the chat component and the
/// underlying composer state, allowing user input to be dynamically updated
/// and managed. The setter also triggers state changes within the composer
/// to track whether the user has drafted any input.
/// to track whether the user has drafted any input, and hands the input to
/// the draft of the open chat.
/// </remarks>
private string UserInput
{
get => this.ComposerState.UserInput;
set => this.ComposerState.SetUserInput(value);
set
{
this.ComposerState.SetUserInput(value);
this.KeepComposerInDraft();
// Also for text which arrives without a key, e.g., pasted with the mouse:
this.hasUnsavedChanges = true;
}
}
#region Overrides of ComponentBase
@@ -250,6 +292,12 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
//
// 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:
//
this.locationChangingRegistration = this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChangingAsync);
await this.RefreshCulture();
//
@@ -260,6 +308,13 @@ public partial class ChatComponent : MSGComponentBase
this.tokenTracker = new(this.RecountTokensAsync, this.TokenCountQuietTime, TOKEN_COUNT_HEARTBEAT);
this.tokenTracker.Start();
this.draftAutosave = new(
DRAFT_AUTOSAVE_DELAY,
chat => WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)),
chat => this.AIJobService.IsChatGenerationActive(chat.ChatId),
WorkspaceBehaviour.StoreChatAsync,
this.Logger);
// Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@@ -293,8 +348,13 @@ public partial class ChatComponent : MSGComponentBase
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
this.ChatThread = deferredRequest.ChatThread;
//
// Use chat thread sent by the user, with a draft of its own. The thread is a copy of
// the assistant's one, made with a with expression, which shares the draft. An assistant
// has no composer, so its draft is always empty, but the next chat it starts would
// otherwise begin with whatever the user writes in this one.
//
this.ChatThread = deferredRequest.ChatThread with { Draft = new() };
this.ChatThread.IncludeDateTime = true;
this.ApplyToolSelectionOfLoadedChat();
@@ -305,7 +365,11 @@ public partial class ChatComponent : MSGComponentBase
//
if (deferredRequest.ApplySelectedChatTemplateToComposer && !this.ComposerState.HasUserDraft)
this.ComposerState.ApplyTemplate(this.SettingsManager.GetChatTemplateById(this.ChatThread.SelectedChatTemplate));
// That draft is the message of the incoming chat now:
if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now.");
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
@@ -459,12 +523,7 @@ public partial class ChatComponent : MSGComponentBase
if (firstRender && this.ChatThread is not null && this.mustStoreChat)
{
this.mustStoreChat = false;
if(this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
@@ -543,6 +602,14 @@ public partial class ChatComponent : MSGComponentBase
var chatId = this.ChatThread?.ChatId ?? Guid.Empty;
var workspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty;
//
// The first chat of this component is no chat being left. The component appears anew, e.g.,
// whenever the workspace sidebar is shown or hidden, while the page keeps the chat and the
// composer:
//
var isFirstChat = !this.hasAppliedChatParameter;
this.hasAppliedChatParameter = true;
if (this.loadedParameterChatId == chatId && this.loadedParameterWorkspaceId == workspaceId)
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
@@ -559,8 +626,13 @@ public partial class ChatComponent : MSGComponentBase
return;
}
var leftChatHadNoThread = !isFirstChat && this.loadedParameterChatId == Guid.Empty;
this.loadedParameterChatId = chatId;
this.loadedParameterWorkspaceId = workspaceId;
// The chat being left keeps the message the user started in it:
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(leftChatHadNoThread);
await this.LoadedChatChanged(notifyParent: false);
}
@@ -857,6 +929,11 @@ public partial class ChatComponent : MSGComponentBase
SelectedProfile = this.currentProfile.Id,
};
// The copy shares the draft, but storing the former thread would bring the former profile back:
if (this.unstoredChat?.ChatId == this.ChatThread.ChatId)
this.unstoredChat = this.ChatThread;
this.draftAutosave?.Follow(this.ChatThread);
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
}
@@ -1007,6 +1084,7 @@ public partial class ChatComponent : MSGComponentBase
this.dataSourceSelectionComponent.Hide();
this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
this.KeepComposerInDraft();
this.hasUnsavedChanges = true;
}
@@ -1016,9 +1094,141 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
this.KeepComposerInDraft();
this.hasUnsavedChanges = true;
}
/// <summary>
/// Hands what the user changed in the composer to the draft of the open chat, and stores the chat
/// once the user paused.
/// </summary>
/// <remarks>
/// Only for changes of the user: a chat template fills the composer as well, but that is no message
/// anybody started, so it never reaches the draft on its own. The draft follows the composer in
/// every storage mode, so storing the chat by hand keeps the message, too. Storing it without being
/// asked happens only while chats are stored automatically, though, because it stores every other
/// change of the chat along with it.
/// </remarks>
private void KeepComposerInDraft()
{
if (this.ChatThread is null)
return;
this.MirrorComposerIntoDraft(this.ChatThread);
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
this.draftAutosave?.Schedule(this.ChatThread);
}
/// <summary>
/// Puts the text and the attachments of the composer into the draft of the given chat.
/// </summary>
/// <param name="chat">The chat the message in the composer belongs to.</param>
private void MirrorComposerIntoDraft(ChatThread chat)
{
chat.Draft.SetText(this.ComposerState.UserInput);
chat.Draft.MirrorAttachments(this.ComposerState.FileAttachments);
}
/// <summary>
/// Stores the chat being left with the message the user started in it, if that is still pending.
/// </summary>
private async Task FlushDraftAsync()
{
if (this.draftAutosave is not null)
await this.draftAutosave.FlushAsync();
}
/// <summary>
/// Stores a new chat which is being left, when the user started a message in it.
/// </summary>
/// <remarks>
/// A new chat reaches the disk together with its first message. Whoever leaves it before would
/// lose the message, so the chat is stored now, with the message as its draft. That happens only
/// while chats are stored automatically, and only for a message of the user: the text of a chat
/// template alone is nobody's message, and storing it would fill the list with chats nobody
/// started.<br/><br/>
///
/// A new chat comes in two kinds. Usually, it has no thread before its first message, and the
/// 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
/// 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/>
///
/// 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.
/// </remarks>
/// <param name="hadNoThread">Whether the chat being left had no thread, so its message is in the composer only.</param>
private async Task PreserveNewChatDraftAsync(bool hadNoThread)
{
// Whatever happens below, the new chat is left now:
var unstoredChatBeingLeft = this.unstoredChat;
this.unstoredChat = null;
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
ChatThread chat;
if (hadNoThread)
{
if (!this.ComposerState.HasVisibleUserDraft)
return;
this.RefreshCurrentProfileAndChatTemplate();
chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput));
this.MirrorComposerIntoDraft(chat);
this.ComposerState.Clear();
}
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
chat = unstoredChatBeingLeft;
else
return;
try
{
await this.StoreAndListChatAsync(chat);
}
catch (Exception e)
{
this.Logger.LogError(e, $"The new chat '{chat.ChatId}' could not be stored with the message the user started in it.");
}
}
/// <summary>
/// Stores the given chat, through the workspace component whenever there is one.
/// </summary>
/// <remarks>
/// The workspace component updates its tree along with storing, so it lists a new chat right
/// away, and a changed one with its new name and time.
/// </remarks>
/// <param name="chat">The chat to store.</param>
private Task StoreAndListChatAsync(ChatThread chat) => this.Workspaces is not null ? this.Workspaces.StoreChatAsync(chat) : WorkspaceBehaviour.StoreChatAsync(chat);
/// <summary>
/// Stores a new chat before the user leaves the chat page, when the user started a message in it.
/// </summary>
/// <remarks>
/// The page takes the composer along when it goes, and with it the message of a chat which has
/// no thread yet. This is a handler of its own rather than a message of the bus: the navigation
/// waits for its handlers, while the bus hands a message over without waiting, so the page could
/// be gone before the chat is stored.<br/><br/>
///
/// Only while chats are stored automatically. Otherwise, nothing is preserved anyway, and the
/// navigation may still be canceled after the user was asked about unsaved changes, so the new
/// chat has to stay as it is.
/// </remarks>
/// <param name="context">The navigation which is about to happen.</param>
private async ValueTask OnLocationChangingAsync(LocationChangingContext context)
{
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
// Opening the chat page again keeps the page, and the composer with it:
if (string.Equals(this.NavigationManager.ToAbsoluteUri(context.TargetLocation).AbsolutePath, Routes.CHAT, StringComparison.OrdinalIgnoreCase))
return;
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
}
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath)
{
@@ -1031,21 +1241,18 @@ public partial class ChatComponent : MSGComponentBase
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath))
: promptName;
this.ChatThread = this.NewChatThread(threadName) with
{
DataSourceOptions = this.earlyDataSourceOptions,
};
this.ChatThread = this.NewChatThread(threadName);
// A message the user started belongs to the new chat, so it is stored with it:
if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread);
//
// Like saving, this goes through the workspace component whenever there is one, so its tree
// lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent:
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.SyncForegroundChatAsync();
@@ -1073,11 +1280,7 @@ public partial class ChatComponent : MSGComponentBase
// Create a new chat thread if necessary:
if (this.ChatThread is null)
{
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with
{
DataSourceOptions = this.earlyDataSourceOptions,
};
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput));
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
@@ -1112,7 +1315,7 @@ public partial class ChatComponent : MSGComponentBase
Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments,
};
this.ChatThread.PendingMediaTranscripts.Clear();
this.ChatThread.Draft.Clear();
//
// Add the user message to the thread:
@@ -1163,12 +1366,16 @@ public partial class ChatComponent : MSGComponentBase
Content = aiText,
});
// Clear the input field:
await this.inputField.FocusAsync();
this.ComposerState.Clear();
await this.inputField.BlurAsync();
//
// Clear the input field, unless the answer is only generated again. The message the user
// has started to write in the meantime is no part of that, so it stays where it is:
//
if (!reuseLastUserPrompt)
{
await this.inputField.FocusAsync();
this.ComposerState.Clear();
await this.inputField.BlurAsync();
}
// Enable the stream state for the chat component:
this.hasUnsavedChanges = true;
@@ -1185,6 +1392,9 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.RuntimeAssistantName = string.Empty;
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, this.ChatThread.RequiredOutboundDataRestriction.Restriction);
// From here on, the AI job stores the chat, in every storage mode:
this.unstoredChat = null;
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = this.ChatThread,
@@ -1242,17 +1452,11 @@ public partial class ChatComponent : MSGComponentBase
if (!this.CanThreadBeSaved)
return;
//
// When the workspace component is visible, we store the chat
// through the workspace component. The advantage of this is that
// the workspace gets updated automatically when the chat is saved.
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
this.hasUnsavedChanges = false;
// A new chat is on the disk now, like any other:
this.unstoredChat = null;
}
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
@@ -1274,6 +1478,17 @@ public partial class ChatComponent : MSGComponentBase
return;
}
//
// The chat being left keeps the message the user started in it. This comes before deleting
// the chat, so that storing it can never bring it back afterward. A new chat which never
// reached the disk is simply dropped when the user wants it deleted:
//
if (deletePreviousChat)
this.unstoredChat = null;
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
//
// Delete the previous chat when desired and necessary:
//
@@ -1348,9 +1563,10 @@ public partial class ChatComponent : MSGComponentBase
//
// When the user wants to start a new chat in the same workspace, we have to
// reset the chat thread only. The workspace id and the workspace name remain
// the same:
// the same. The new chat reaches the disk only once there is something to keep:
//
this.ChatThread = this.NewChatThread(string.Empty);
this.unstoredChat = this.ChatThread;
}
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
@@ -1467,14 +1683,17 @@ public partial class ChatComponent : MSGComponentBase
if (copy is null)
return;
var transcriptsOfTheOriginal = sourceChat.PendingMediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
var transcriptsOfTheOriginal = sourceChat.Draft.MediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
this.ComposerState.FileAttachments.RemoveWhere(attachment => transcriptsOfTheOriginal.Contains(attachment.FilePath));
foreach (var transcript in copy.PendingMediaTranscripts)
foreach (var transcript in copy.Draft.MediaTranscripts)
this.ComposerState.FileAttachments.Add(transcript);
this.ChatThread = copy;
this.hasUnsavedChanges = false;
// The copy carries the message along, so a new chat which never reached the disk stays that way:
this.unstoredChat = null;
await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter();
await this.SyncWorkspaceHeaderWithChatThreadAsync();
@@ -1489,6 +1708,9 @@ public partial class ChatComponent : MSGComponentBase
if (this.ChatThread is not null)
{
this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread;
// The message the user had started in this chat comes back:
this.ComposerState.RestoreFromDraft(this.ChatThread.Draft);
this.loadedParameterChatId = this.ChatThread.ChatId;
this.loadedParameterWorkspaceId = this.ChatThread.WorkspaceId;
if (notifyParent)
@@ -1533,6 +1755,8 @@ public partial class ChatComponent : MSGComponentBase
private async Task ResetState()
{
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.ClearWorkspaceHeaderState();
@@ -1617,7 +1841,7 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
if (block is not ContentText)
return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last();
@@ -1625,9 +1849,9 @@ public partial class ChatComponent : MSGComponentBase
if(lastBlockContent is null)
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.RemoveForEditing(block);
this.ChatThread.Remove(lastBlockContent);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
@@ -1638,19 +1862,28 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
if (block is not ContentText)
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.RemoveForEditing(block);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
}
private void RestoreComposerFromTextBlock(ContentText textBlock)
/// <summary>
/// Puts the message being edited into the composer, after the chat made it its draft again.
/// </summary>
/// <remarks>
/// Taken from the draft rather than from the message, so the composer also shows the transcripts
/// the draft held before. The chat is then stored like after any other change of the draft: who
/// edits a message and goes elsewhere finds it in the composer when coming back.
/// </remarks>
private void RestoreComposerForEditing()
{
this.ComposerState.RestoreFromTextBlock(textBlock);
this.ComposerState.RestoreFromDraft(this.ChatThread!.Draft);
this.KeepComposerInDraft();
}
/// <summary>
@@ -1802,13 +2035,14 @@ public partial class ChatComponent : MSGComponentBase
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
/// they send their first message.
///
/// The data source options are left out on purpose: two of the three callers set them and the
/// third replaces them right afterwards, so this stays the part they agree on.
/// The data source options are those chosen before the chat had a thread. Starting a new chat in
/// a workspace replaces them right afterward with those its chat template asks for.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name) => new()
{
DataSourceOptions = this.earlyDataSourceOptions,
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
@@ -1882,8 +2116,13 @@ public partial class ChatComponent : MSGComponentBase
if (this.IsCurrentChatStreaming)
return Task.FromResult((TResult?) (object) false);
return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft));
//
// A message the user started counts as unsaved on its own only while there is no chat
// to keep it. Otherwise, it is part of the chat: changing it counts as a change of the
// chat, and a message which came back from the disk with the chat is saved already.
//
return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || (this.ChatThread is null && this.ComposerState.HasVisibleUserDraft)));
}
return Task.FromResult(default(TResult));
@@ -1896,10 +2135,23 @@ public partial class ChatComponent : MSGComponentBase
protected override async ValueTask DisposeResourcesAsync()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
this.locationChangingRegistration?.Dispose();
if (this.tokenTracker is not null)
await this.tokenTracker.DisposeAsync();
// Stores what is still pending, before the chat is left:
if (this.draftAutosave is not null)
await this.draftAutosave.DisposeAsync();
//
// So does a new chat started in a workspace, since the next component of the page, e.g.,
// after the workspace sidebar was shown or hidden, cannot tell it from a deleted one. A chat
// without a thread stays in the composer, though: the page keeps that, and its next
// component goes on with it. Leaving the page stores it, see OnLocationChangingAsync.
//
await this.PreserveNewChatDraftAsync(hadNoThread: false);
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
await this.SaveThread();
@@ -56,10 +56,18 @@ public sealed class ChatComposerState
this.HasUserDraft = false;
}
public void RestoreFromTextBlock(ContentText textBlock)
/// <summary>
/// Brings the message the user had not sent yet back into the composer.
/// </summary>
/// <remarks>
/// The draft holds only what the user wrote or attached, so whatever it holds counts as their
/// message. An empty draft leaves the composer free for a chat template.
/// </remarks>
/// <param name="draft">The draft of the chat.</param>
public void RestoreFromDraft(ChatDraft draft)
{
this.UserInput = textBlock.Text;
this.ReplaceFileAttachments(textBlock.FileAttachments);
this.HasUserDraft = true;
this.UserInput = draft.Text;
this.ReplaceFileAttachments(draft.FileAttachments);
this.HasUserDraft = !draft.IsEmpty;
}
}
@@ -1,108 +0,0 @@
using Microsoft.AspNetCore.Components;
using Timer = System.Timers.Timer;
namespace AIStudio.Components;
/// <summary>
/// Debounced multi-line text input built on <see cref="MudTextField{T}"/>.
/// Keeps the base API while adding a debounce timer.
/// Callers can override any property as usual.
/// </summary>
public class UserPromptComponent<T> : MudTextField<T>, IDisposable
{
[Parameter]
public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800);
[Parameter]
public Func<string, Task> WhenTextChangedAsync { get; set; } = _ => Task.CompletedTask;
private readonly Timer debounceTimer = new();
private string text = string.Empty;
private string lastParameterText = string.Empty;
private string lastNotifiedText = string.Empty;
private bool isInitialized;
private bool isDisposed;
protected override async Task OnInitializedAsync()
{
this.text = this.Text ?? string.Empty;
this.lastParameterText = this.text;
this.lastNotifiedText = this.text;
this.debounceTimer.AutoReset = false;
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
this.debounceTimer.Elapsed += this.WhenDebounceElapsed;
this.isInitialized = true;
await base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
// Ensure the timer uses the latest debouncing interval:
if (!this.isInitialized || this.isDisposed)
{
await base.OnParametersSetAsync();
return;
}
if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1)
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
// Only sync when the parent's parameter actually changed since the last change:
if (this.Text != this.lastParameterText)
{
this.text = this.Text ?? string.Empty;
this.lastParameterText = this.text;
}
this.debounceTimer.Stop();
this.debounceTimer.Start();
await base.OnParametersSetAsync();
}
private void WhenDebounceElapsed(object? sender, System.Timers.ElapsedEventArgs args)
{
this.debounceTimer.Stop();
//
// The timer runs on its own thread and may still fire while this component is being torn
// down. Notifying a renderer which is already gone would throw on that thread, where no
// caller is left to handle it.
//
if (this.isDisposed || this.text == this.lastNotifiedText)
return;
this.lastNotifiedText = this.text;
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: notifying about changed text");
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: handling changed text asynchronously");
}
#region IDisposable
public void Dispose()
{
if (this.isDisposed)
return;
//
// Set before stopping the timer: the handler might be running on the timer thread right
// now, and this is what tells it to leave the gone renderer alone.
//
this.isDisposed = true;
try
{
this.debounceTimer.Elapsed -= this.WhenDebounceElapsed;
this.debounceTimer.Stop();
this.debounceTimer.Dispose();
}
catch
{
// ignore
}
GC.SuppressFinalize(this);
}
#endregion
}
@@ -313,9 +313,8 @@ public sealed class MediaTranscriptionService(
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text)
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
if (persistentStorage is null && ownerChat is not null && attachment is ManagedTranscriptAttachment managed
&& ownerChat.PendingMediaTranscripts.All(existing => existing.FilePath != managed.FilePath))
ownerChat.PendingMediaTranscripts.Add(managed);
if (persistentStorage is null && ownerChat is not null && attachment is ManagedTranscriptAttachment managed)
ownerChat.Draft.AddAttachments([managed]);
if (isPersistedChat)
await WorkspaceBehaviour.StoreChatAsync(ownerChat!);
@@ -895,13 +895,24 @@ public static class WorkspaceBehaviour
if (dialogResult is null || dialogResult.Canceled)
return null;
return await CopyChatAsync(sourceChat, (dialogResult.Data as string)!);
}
/// <summary>
/// Copies a chat into a new chat of the same workspace, including its managed transcript files.
/// </summary>
/// <param name="sourceChat">The chat to copy. Its own files and state stay untouched.</param>
/// <param name="name">The name of the copy.</param>
/// <returns>The persisted copy.</returns>
internal static async Task<ChatThread> CopyChatAsync(ChatThread sourceChat, string name)
{
var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
var copiedChat = JsonSerializer.Deserialize<ChatThread>(serializedChat, JSON_OPTIONS)
?? throw new InvalidOperationException("The chat could not be copied.");
copiedChat = copiedChat with
{
ChatId = Guid.NewGuid(),
Name = (dialogResult.Data as string)!,
Name = name,
};
var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
@@ -944,8 +955,9 @@ public static class WorkspaceBehaviour
}
}
for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
chat.Draft.ReplaceAttachments(attachment => attachment is ManagedTranscriptAttachment transcript
? CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths)
: attachment);
}
private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
@@ -1127,8 +1139,7 @@ public static class WorkspaceBehaviour
// their old paths, the draft points at a directory which is gone after the move, and the
// transcripts would never reach the message.
//
for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
chat.PendingMediaTranscripts[index] = (ManagedTranscriptAttachment)RelocateAttachment(chat.PendingMediaTranscripts[index], sourceDirectory, targetDirectory);
chat.Draft.ReplaceAttachments(attachment => RelocateAttachment(attachment, sourceDirectory, targetDirectory));
}
/// <summary>
@@ -13,6 +13,8 @@
- Improved the question whether AI Studio should transcribe your audio and video files. It now waits until you choose Yes or No, so a click next to it no longer drops your files.
- Improved how fast AI Studio starts when the configuration of your organization includes tokenizers. AI Studio now checks such a tokenizer only when it has changed.
- Improved the button in the chat for moving the chat to a workspace. You can now use it whenever the workspace list lets you move the chat as well, for example, right after you attached an audio or video file and before you sent your first message.
- Improved how AI Studio keeps the message you have not sent yet. When AI Studio stores your chats automatically, your message and its attachments now stay with the chat, and they are back when you open the chat again, for example, after you switched to another chat.
- Improved how AI Studio keeps the message of a new chat. When AI Studio stores your chats automatically and you leave a new chat before you send your message, AI Studio now stores the chat with your message. You find it among your disappearing chats, or in its workspace when you started it there.
- Changed the banner on the start page to a fresh design that shows what AI Studio can do today.
- Fixed a warning about the confidence level that stayed in the dialog for adding a local data source, even after you chose an embedding provider that meets it. The dialog now checks again whenever you change either of them, so a warning also appears when you switch to a provider that falls short.
- Fixed two confidence levels sharing the same name in the German user interface. Moderate is called "Mäßig" again, and Medium stays "Mittel".
@@ -35,6 +37,8 @@
- 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 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 regenerating an answer clearing the message you had started to write.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
+192
View File
@@ -0,0 +1,192 @@
using AIStudio.Chat;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks when a chat gets stored with the message the user has not sent yet.
/// </summary>
/// <remarks>
/// Storing writes the whole conversation, so it waits until the user paused, and it happens once more
/// when the user leaves the chat. A chat being answered is stored by its AI job instead, and a chat which
/// is not on the disk is left to whoever creates it: when it is gone, it was deleted.
/// </remarks>
[TestFixture]
public sealed class ChatDraftAutosaveTests
{
private static readonly TimeSpan SHORT_DELAY = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan LONG_DELAY = TimeSpan.FromHours(1);
private static readonly TimeSpan WAIT_LIMIT = TimeSpan.FromSeconds(10);
[Test]
public async Task ManyChangesInARowAreStoredOnce()
{
var storage = new FakeStorage();
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
var chat = new ChatThread { ChatId = Guid.NewGuid() };
for (var keystroke = 0; keystroke < 5; keystroke++)
autosave.Schedule(chat);
await storage.WaitForStoreAsync();
await Task.Delay(SHORT_DELAY * 4);
Assert.That(storage.StoredChats, Has.Count.EqualTo(1), "Every change starts the waiting anew, so typing a word writes the chat once, not once per letter.");
}
[Test]
public async Task LeavingTheChatStoresItRightAway()
{
var storage = new FakeStorage();
await using var autosave = storage.CreateAutosave(LONG_DELAY);
var chat = new ChatThread { ChatId = Guid.NewGuid() };
autosave.Schedule(chat);
await autosave.FlushAsync();
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "The chat being left has to be on the disk before the next one opens.");
}
[Test]
public async Task AChatBeingAnsweredIsStoredOnceTheAnswerIsDone()
{
var storage = new FakeStorage { IsBusy = true };
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
var chat = new ChatThread { ChatId = Guid.NewGuid() };
autosave.Schedule(chat);
await Task.Delay(SHORT_DELAY * 4);
var storedWhileBusy = storage.StoredChats.Count;
storage.IsBusy = false;
await storage.WaitForStoreAsync();
Assert.Multiple(() =>
{
Assert.That(storedWhileBusy, Is.Zero, "The AI job changes the conversation meanwhile, so storing it from elsewhere could read it in the middle of a change.");
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "Once the answer is done, the change must still reach the disk.");
});
}
[Test]
public async Task LeavingAChatBeingAnsweredLeavesItToTheJob()
{
var storage = new FakeStorage { IsBusy = true };
await using var autosave = storage.CreateAutosave(LONG_DELAY);
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
await autosave.FlushAsync();
Assert.That(storage.StoredChats, Is.Empty, "The AI job stores the chat once more when the answer is done, together with the draft it shares.");
}
[Test]
public async Task AChatWhichIsNotOnTheDiskIsNeverStored()
{
var storage = new FakeStorage { IsStored = false };
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
await Task.Delay(SHORT_DELAY * 4);
await autosave.FlushAsync();
Assert.That(storage.StoredChats, Is.Empty, "A chat gone from the disk was deleted, and storing it would bring it back.");
}
[Test]
public async Task TheNewestCopyOfTheChatIsStored()
{
var storage = new FakeStorage();
await using var autosave = storage.CreateAutosave(LONG_DELAY);
var chat = new ChatThread { ChatId = Guid.NewGuid() };
autosave.Schedule(chat);
// What the chat does when the user picks another profile:
var withOtherProfile = chat with { SelectedProfile = Guid.NewGuid().ToString() };
autosave.Follow(withOtherProfile);
await autosave.FlushAsync();
Assert.That(storage.StoredChats.Single(), Is.SameAs(withOtherProfile), "Storing the former copy would bring the former profile back.");
}
[Test]
public async Task AChangeInAnotherChatStoresThePreviousOne()
{
var storage = new FakeStorage();
await using var autosave = storage.CreateAutosave(LONG_DELAY);
var previousChat = new ChatThread { ChatId = Guid.NewGuid() };
autosave.Schedule(previousChat);
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
await storage.WaitForStoreAsync();
Assert.That(storage.StoredChats.Single(), Is.SameAs(previousChat), "The change in one chat must not cost the other its draft.");
}
[Test]
public async Task DisposingStoresWhatIsPending()
{
var storage = new FakeStorage();
var autosave = storage.CreateAutosave(LONG_DELAY);
var chat = new ChatThread { ChatId = Guid.NewGuid() };
autosave.Schedule(chat);
await autosave.DisposeAsync();
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
await autosave.FlushAsync();
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "Leaving the chat page disposes the chat, which counts as leaving the chat. What comes later belongs to no chat on the screen.");
}
/// <summary>
/// Stands in for the disk and for the AI jobs, and records which chats were stored.
/// </summary>
private sealed class FakeStorage
{
private readonly Lock storedLock = new();
private readonly List<ChatThread> storedChats = [];
private readonly SemaphoreSlim storeSignal = new(0);
private volatile bool isBusy;
private volatile bool isStored = true;
public bool IsBusy
{
get => this.isBusy;
set => this.isBusy = value;
}
public bool IsStored
{
get => this.isStored;
init => this.isStored = value;
}
public IReadOnlyList<ChatThread> StoredChats
{
get
{
lock (this.storedLock)
return [..this.storedChats];
}
}
public ChatDraftAutosave CreateAutosave(TimeSpan delay) => new(delay, _ => this.IsStored, _ => this.IsBusy, this.StoreAsync, NullLogger.Instance);
public async Task WaitForStoreAsync()
{
if (!await this.storeSignal.WaitAsync(WAIT_LIMIT))
Assert.Fail("The chat was never stored.");
}
private Task<bool> StoreAsync(ChatThread chat)
{
lock (this.storedLock)
this.storedChats.Add(chat);
this.storeSignal.Release();
return Task.FromResult(true);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Tools;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks the draft of a chat, the message the user has not sent yet.
/// </summary>
/// <remarks>
/// The draft is stored with its chat, so the message is still there when the user comes back to it.
/// Two parties change the draft: the user in the composer, and the media transcription service,
/// which adds a finished transcript before the composer shows it. That is why a transcript only
/// leaves the draft when the user removes it, or when the message is sent.
/// </remarks>
[TestFixture]
public sealed class ChatDraftTests
{
private static readonly FileAttachment AGENDA = new(FileAttachmentType.DOCUMENT, "agenda.pdf", "/documents/agenda.pdf", 1024);
private static readonly FileAttachment MINUTES = new(FileAttachmentType.DOCUMENT, "minutes.pdf", "/documents/minutes.pdf", 2048);
private static readonly FileAttachmentImage CHART = new("chart.png", "/documents/chart.png", 4096);
private static readonly ManagedTranscriptAttachment TRANSCRIPT = new("meeting-transcript-0001.md", "/data/tempChats/meeting-transcript-0001.md", 512, "meeting.mp4", false);
private static readonly ManagedTranscriptAttachment STAGED_TRANSCRIPT = new("4f1c.md", "/data/media-staging/4f1c.md", 256, "kickoff.mp4", true);
[Test]
public void MirroringTheComposerKeepsATranscriptItDoesNotShowYet()
{
var draft = new ChatDraft();
draft.AddAttachments([AGENDA]);
// The transcription service finished, and the composer has not shown the transcript yet:
draft.AddAttachments([TRANSCRIPT]);
// Meanwhile, the user replaced the agenda by the minutes:
draft.MirrorAttachments([MINUTES]);
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { MINUTES, TRANSCRIPT }), "The composer decides on the other attachments, but a transcript it does not show yet has to stay, or its file is left without an owner.");
}
[Test]
public void AnAttachmentIsAddedOnlyOnce()
{
var draft = new ChatDraft();
var added = draft.AddAttachments([TRANSCRIPT]);
var addedAgain = draft.AddAttachments([TRANSCRIPT with { FileSizeBytes = 1024 }]);
Assert.Multiple(() =>
{
Assert.That(added, Is.True);
Assert.That(addedAgain, Is.False, "The same file counts once, even in a newer state.");
Assert.That(draft.FileAttachments, Is.EqualTo(new FileAttachment[] { TRANSCRIPT }), "The composer would show the transcript twice otherwise.");
});
}
[Test]
public void RemovingAnAttachmentFindsItByItsPath()
{
var draft = new ChatDraft();
draft.AddAttachments([AGENDA, TRANSCRIPT]);
// The composer holds the transcript as it normalized it, with the current size of its file:
draft.RemoveAttachments([TRANSCRIPT with { FileSizeBytes = 1024 }]);
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { AGENDA }), "The transcript the user removed must not come back when the chat is opened again.");
}
[Test]
public void ReplacingTheAttachmentsKeepsTheirKind()
{
var draft = new ChatDraft();
draft.AddAttachments([AGENDA, TRANSCRIPT]);
draft.ReplaceAttachments(attachment => attachment with { FilePath = $"/moved{attachment.FilePath}" });
Assert.Multiple(() =>
{
Assert.That(draft.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { "/moved/documents/agenda.pdf", "/moved/data/tempChats/meeting-transcript-0001.md" }));
Assert.That(draft.MediaTranscripts, Has.Count.EqualTo(1), "A transcript stays a transcript, so removing it from the composer still deletes its file.");
});
}
[Test]
public void EditingAMessageKeepsTheTranscriptsOfTheDraft()
{
var draft = new ChatDraft();
draft.SetText("An older draft");
draft.AddAttachments([AGENDA, TRANSCRIPT]);
draft.RestoreFromMessage(new ContentText { Text = "Summarize the minutes", FileAttachments = [MINUTES] });
Assert.Multiple(() =>
{
Assert.That(draft.Text, Is.EqualTo("Summarize the minutes"), "The message to edit replaces the older draft.");
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { MINUTES, TRANSCRIPT }), "The draft is the only owner of its transcripts, so dropping them would leave their files behind.");
});
}
[Test]
public void SendingEmptiesTheDraft()
{
var draft = new ChatDraft();
draft.SetText("Summarize the meeting");
draft.AddAttachments([TRANSCRIPT]);
draft.Clear();
Assert.That(draft.IsEmpty, Is.True, "The attachments belong to the sent message now, and a transcript has only one owner.");
}
[Test]
public void TheDraftKeepsTheKindOfEveryAttachmentWhenStored()
{
var thread = new ChatThread { ChatId = Guid.NewGuid(), Name = "Meeting notes" };
thread.Draft.SetText("Summarize the meeting");
thread.Draft.AddAttachments([AGENDA, CHART, STAGED_TRANSCRIPT]);
var loaded = JsonSerializer.Deserialize<ChatThread>(JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), WorkspaceBehaviour.JSON_OPTIONS)!;
Assert.Multiple(() =>
{
Assert.That(loaded.Draft.Text, Is.EqualTo("Summarize the meeting"));
Assert.That(loaded.Draft.FileAttachments, Is.EqualTo(new[] { AGENDA, CHART, STAGED_TRANSCRIPT }), "A transcript loaded as a plain document would no longer be managed, and its file would never be deleted.");
Assert.That(loaded.Draft.MediaTranscripts.Single().IsStaged, Is.True, "A staged transcript has to move into the chat directory once the chat is stored.");
});
}
[Test]
public void AChatOfTheFormerVersionBringsItsPendingTranscriptsIntoTheDraft()
{
// How v26.9.1 stored a chat with a transcript which was not sent yet, without a type discriminator:
const string CHAT_WITH_PENDING_TRANSCRIPTS = """
{
"chat_id": "2b7c9e1f-4a6d-4c8b-9e3f-1d5a7c9b2e4f",
"pending_media_transcripts": [
{
"original_file_name": "meeting.mp4",
"is_staged": false,
"type": "DOCUMENT",
"file_name": "meeting-transcript-0001.md",
"file_path": "/data/tempChats/meeting-transcript-0001.md",
"file_size_bytes": 512,
"is_forbidden": false,
"is_valid": true,
"is_image": false,
"file_path_as_url": "/local/file?path=%2Fdata%2FtempChats%2Fmeeting-transcript-0001.md",
"exists": true
}
],
"name": "Meeting notes",
"blocks": []
}
""";
var loaded = JsonSerializer.Deserialize<ChatThread>(CHAT_WITH_PENDING_TRANSCRIPTS, WorkspaceBehaviour.JSON_OPTIONS)!;
var stored = JsonSerializer.Serialize(loaded, WorkspaceBehaviour.JSON_OPTIONS);
Assert.Multiple(() =>
{
Assert.That(loaded.Draft.FileAttachments, Is.EqualTo(new FileAttachment[] { TRANSCRIPT }), "Opening the chat has to bring the transcript back into the composer.");
Assert.That(stored, Does.Not.Contain("pending_media_transcripts"), "The draft holds the transcript now, so storing the chat must not write the former list again.");
Assert.That(stored, Does.Contain("\"draft\""));
});
}
[Test]
public void AChatWithoutADraftOpensWithAnEmptyOne()
{
const string CHAT_WITHOUT_DRAFT = """
{
"chat_id": "2b7c9e1f-4a6d-4c8b-9e3f-1d5a7c9b2e4f",
"name": "An older chat",
"blocks": []
}
""";
var loaded = JsonSerializer.Deserialize<ChatThread>(CHAT_WITHOUT_DRAFT, WorkspaceBehaviour.JSON_OPTIONS);
Assert.That(loaded?.Draft.IsEmpty, Is.True);
}
[Test]
public void ACopyOfTheSameChatSharesTheDraft()
{
var thread = new ChatThread { ChatId = Guid.NewGuid() };
// What the chat does when the user picks another profile:
var withOtherProfile = thread with { SelectedProfile = Guid.NewGuid().ToString() };
withOtherProfile.Draft.SetText("Summarize the meeting");
Assert.That(thread.Draft.Text, Is.EqualTo("Summarize the meeting"), "Both are the same chat, and the transcription service may still hold the former one.");
}
}
+8 -11
View File
@@ -63,16 +63,17 @@ public sealed class ChatThreadEditTests
var composer = new ChatComposerState();
// What the chat does when the user stops the answer and edits the question:
composer.RestoreFromTextBlock((ContentText)question.Content!);
thread.RemoveForEditing(question.Content!);
thread.Remove(answer.Content!);
composer.RestoreFromDraft(thread.Draft);
Assert.Multiple(() =>
{
Assert.That(thread.Blocks, Is.Empty, "Both the question and the stopped answer leave the chat.");
Assert.That(File.Exists(transcript.FilePath), Is.True, "The composer still carries the transcript, so its file has to stay.");
Assert.That(composer.FileAttachments.Single().Exists, Is.True, "The edited message goes out with the transcript, not with a reference to a deleted file.");
Assert.That(thread.PendingMediaTranscripts.Select(x => x.FilePath), Is.EqualTo(new[] { transcript.FilePath }), "The draft owns the transcript now: opening the chat again brings it back, and removing it from the composer deletes the file.");
Assert.That(thread.Draft.MediaTranscripts.Select(x => x.FilePath), Is.EqualTo(new[] { transcript.FilePath }), "The draft owns the transcript now: opening the chat again brings it back, and removing it from the composer deletes the file.");
Assert.That(thread.Draft.Text, Is.EqualTo("Summarize the meeting"), "The message being edited is the draft now, its text included.");
});
}
@@ -82,16 +83,12 @@ public sealed class ChatThreadEditTests
var chatId = Guid.NewGuid();
var transcript = this.CreateTranscript(chatId, "meeting");
var question = Block(ChatRole.USER, "Summarize the meeting", 1, transcript);
var thread = new ChatThread
{
ChatId = chatId,
PendingMediaTranscripts = [transcript],
Blocks = [question],
};
var thread = new ChatThread { ChatId = chatId, Blocks = [question] };
thread.Draft.AddAttachments([transcript]);
thread.RemoveForEditing(question.Content!);
Assert.That(thread.PendingMediaTranscripts, Has.Count.EqualTo(1), "The composer would show the same transcript twice otherwise.");
Assert.That(thread.Draft.FileAttachments, Has.Count.EqualTo(1), "The composer would show the same transcript twice otherwise.");
}
[Test]
@@ -107,7 +104,7 @@ public sealed class ChatThreadEditTests
Assert.Multiple(() =>
{
Assert.That(File.Exists(transcript.FilePath), Is.False, "Nothing refers to the transcript of a deleted message any longer, so its file has to go.");
Assert.That(thread.PendingMediaTranscripts, Is.Empty, "A deleted message hands nothing over to the draft.");
Assert.That(thread.Draft.IsEmpty, Is.True, "A deleted message hands nothing over to the draft.");
});
}
@@ -127,7 +124,7 @@ public sealed class ChatThreadEditTests
Assert.Multiple(() =>
{
Assert.That(thread.Blocks, Has.Count.EqualTo(2), "The message to edit is not part of this chat, so no block goes.");
Assert.That(thread.PendingMediaTranscripts, Is.Empty, "A transcript of a message this chat does not hold never becomes part of its draft.");
Assert.That(thread.Draft.IsEmpty, Is.True, "A message this chat does not hold never becomes its draft.");
Assert.That(File.Exists(transcript.FilePath), Is.True);
});
}
@@ -0,0 +1,64 @@
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Settings;
namespace AIStudio.Tests.Components;
/// <summary>
/// Checks what the composer counts as a message the user started.
/// </summary>
/// <remarks>
/// That decides whether a chat template may still fill the composer, and whether leaving the chat
/// warns about unsaved changes. A draft brought back from a chat is always the user's own, while a
/// chat template only suggests a message.
/// </remarks>
[TestFixture]
public sealed class ChatComposerStateTests
{
[Test]
public void ADraftBroughtBackIsTheMessageOfTheUser()
{
var draft = new ChatDraft();
draft.SetText("Summarize the meeting");
draft.AddAttachments([new FileAttachment(FileAttachmentType.DOCUMENT, "agenda.pdf", "/documents/agenda.pdf", 1024)]);
var composer = new ChatComposerState();
composer.RestoreFromDraft(draft);
Assert.Multiple(() =>
{
Assert.That(composer.UserInput, Is.EqualTo("Summarize the meeting"));
Assert.That(composer.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { "/documents/agenda.pdf" }));
Assert.That(composer.HasUserDraft, Is.True, "A chat template must not replace the message the user came back to.");
});
}
[Test]
public void AnEmptyDraftLeavesTheComposerFree()
{
var composer = new ChatComposerState();
composer.SetUserInput("A message of the chat before");
composer.RestoreFromDraft(new ChatDraft());
Assert.Multiple(() =>
{
Assert.That(composer.UserInput, Is.Empty, "The message of the chat before belongs to that chat.");
Assert.That(composer.HasUserDraft, Is.False, "Nobody started a message in this chat, so a chat template may still fill the composer.");
});
}
[Test]
public void TheMessageOfAChatTemplateIsNoDraftOfTheUser()
{
var composer = new ChatComposerState();
composer.ApplyTemplate(new ChatTemplate { PredefinedUserPrompt = "Translate the following text:" });
Assert.Multiple(() =>
{
Assert.That(composer.UserInput, Is.EqualTo("Translate the following text:"));
Assert.That(composer.HasUserDraft, Is.False, "Leaving the chat must not warn about a message the user never wrote.");
});
}
}
+90
View File
@@ -0,0 +1,90 @@
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what a copy of a chat makes of the message the user has not sent yet.
/// </summary>
/// <remarks>
/// The copy continues where the original stands, so it takes the draft along. A transcript of the
/// draft is a file the chat manages itself, though: removing it from the composer deletes the file.
/// Shared between both chats, a transcript removed in the copy would be missing in the original.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class WorkspaceChatCopyTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public void PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. The chats live below it, and so do their transcripts.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-chat-copy-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
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 TheCopyGetsADraftWithTranscriptsOfItsOwn()
{
var chatId = Guid.NewGuid();
var transcript = this.CreateTranscript(chatId, "meeting");
var original = new ChatThread { ChatId = chatId, Name = "Meeting notes", LastMediaTranscriptNumber = 1 };
original.Draft.SetText("Summarize the meeting");
original.Draft.AddAttachments([transcript]);
var copy = await WorkspaceBehaviour.CopyChatAsync(original, "Copy of Meeting notes");
var copyDirectory = Path.Combine(this.testDirectory, "tempChats", copy.ChatId.ToString());
var copiedTranscript = copy.Draft.MediaTranscripts.Single();
Assert.Multiple(() =>
{
Assert.That(copy.Draft, Is.Not.SameAs(original.Draft), "Writing in the copy must not change the message of the original.");
Assert.That(copy.Draft.Text, Is.EqualTo("Summarize the meeting"), "The copy continues where the original stands, the message being written included.");
Assert.That(copiedTranscript.FilePath, Does.StartWith(copyDirectory), "The copy has to own a transcript of its own.");
Assert.That(File.Exists(copiedTranscript.FilePath), Is.True, "Otherwise the transcript drops out of the message of the copy.");
Assert.That(original.Draft.MediaTranscripts.Single(), Is.EqualTo(transcript), "The original keeps its transcript, with its path.");
Assert.That(File.Exists(transcript.FilePath), Is.True, "Copying a chat must not take anything away from the original.");
});
}
/// <summary>
/// Writes a transcript where a temporary chat keeps its transcripts.
/// </summary>
/// <param name="chatId">The chat which owns the transcript.</param>
/// <param name="stem">The name of the media file, without its extension.</param>
/// <returns>The transcript, as the chat attaches it to a message.</returns>
private ManagedTranscriptAttachment CreateTranscript(Guid chatId, string stem)
{
var transcriptDirectory = Path.Combine(this.testDirectory, "tempChats", chatId.ToString(), "attachments", "transcripts");
Directory.CreateDirectory(transcriptDirectory);
var path = Path.Combine(transcriptDirectory, $"{stem}-transcript-0001.md");
File.WriteAllText(path, $"# Transcription: {stem}.mp4{Environment.NewLine}{Environment.NewLine}Hello and welcome.");
return new ManagedTranscriptAttachment(Path.GetFileName(path), path, new FileInfo(path).Length, $"{stem}.mp4", false);
}
}
+9 -7
View File
@@ -63,7 +63,6 @@ public sealed class WorkspaceChatMoveTests
{
ChatId = chatId,
Name = "Meeting notes",
PendingMediaTranscripts = [draft],
Blocks =
[
new ContentBlock
@@ -75,11 +74,12 @@ public sealed class WorkspaceChatMoveTests
},
],
};
chat.Draft.AddAttachments([draft]);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
var targetDirectory = Path.Combine(this.testDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
var movedDraft = chat.PendingMediaTranscripts.Single();
var movedDraft = chat.Draft.MediaTranscripts.Single();
var movedSent = ((ContentText)chat.Blocks.Single().Content!).FileAttachments.Single();
var stored = JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(Path.Combine(targetDirectory, "thread.json")), WorkspaceBehaviour.JSON_OPTIONS)!;
@@ -89,7 +89,7 @@ public sealed class WorkspaceChatMoveTests
Assert.That(movedDraft.FilePath, Does.StartWith(targetDirectory), "The draft has to point into the directory the chat lives in now.");
Assert.That(File.Exists(movedDraft.FilePath), Is.True, "Otherwise the transcript drops out of the message which is still being written.");
Assert.That(File.Exists(movedSent.FilePath), Is.True, "The transcripts of the sent messages move along, as they always did.");
Assert.That(stored.PendingMediaTranscripts.Single().FilePath, Is.EqualTo(movedDraft.FilePath), "Opening the chat again brings the draft back from the stored chat, so that has to know the new path as well.");
Assert.That(stored.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(movedDraft.FilePath), "Opening the chat again brings the draft back from the stored chat, so that has to know the new path as well.");
});
}
@@ -102,11 +102,12 @@ public sealed class WorkspaceChatMoveTests
// A transcript which is still waiting in the staging area, because its chat was not stored yet when it was made:
var stagingDirectory = Path.Combine(this.testDirectory, "media-staging", Guid.NewGuid().ToString("N"));
var staged = WriteTranscript(stagingDirectory, $"{Guid.NewGuid():N}.md", "meeting", isStaged: true);
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes", PendingMediaTranscripts = [staged] };
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
chat.Draft.AddAttachments([staged]);
await WorkspaceBehaviour.MoveChatAsync(chat, Guid.NewGuid());
Assert.That(chat.PendingMediaTranscripts.Single().FilePath, Is.EqualTo(staged.FilePath), "The move takes only the chat directory along, so a path elsewhere is still right.");
Assert.That(chat.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(staged.FilePath), "The move takes only the chat directory along, so a path elsewhere is still right.");
}
[Test]
@@ -116,7 +117,8 @@ public sealed class WorkspaceChatMoveTests
var workspaceId = Guid.NewGuid();
var transcript = this.CreateTranscript(chatId, "meeting", 1);
var document = FileAttachment.FromPath(Path.Combine(this.testDirectory, "documents", "agenda.pdf"));
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes", PendingMediaTranscripts = [transcript] };
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
chat.Draft.AddAttachments([transcript]);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
@@ -126,7 +128,7 @@ public sealed class WorkspaceChatMoveTests
Assert.Multiple(() =>
{
Assert.That(relocatedTranscript, Is.EqualTo(chat.PendingMediaTranscripts.Single()), "The composer and the draft have to agree, or the composer shows the transcript twice.");
Assert.That(relocatedTranscript, Is.EqualTo(chat.Draft.MediaTranscripts.Single()), "The composer and the draft have to agree, or the composer shows the transcript twice.");
Assert.That(relocatedTranscript.Exists, Is.True, "The message being written has to go out with the transcript.");
Assert.That(relocatedTranscript, Is.InstanceOf<ManagedTranscriptAttachment>(), "The chat still manages the transcript, so removing it from the composer still deletes the file.");
Assert.That(relocatedDocument, Is.SameAs(document), "The move did not touch the document, so it keeps its path.");