mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 21:53:47 +00:00
Kept the unsent message with its chat (#1049)
This commit is contained in:
1 parent
1c13300988
commit
468d28aa94
18 files changed
+1333
-240
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user