using System.Text.Json.Serialization; namespace AIStudio.Chat; /// /// The message of a chat which the user has not sent yet, with its text and its attachments. /// /// /// The draft is stored with its chat, so the message is still there when the user comes back to the /// chat.

/// /// 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.

/// /// 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. ///
public sealed class ChatDraft { private readonly Lock stateLock = new(); /// /// The text of the message. /// [JsonInclude] public string Text { get; private set; } = string.Empty; /// /// The files attached to the message, the transcripts of audio and video files included. /// /// /// 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. /// [JsonInclude] public IReadOnlyList FileAttachments { get; private set; } = []; /// /// Whether the draft holds neither text nor attachments. /// [JsonIgnore] public bool IsEmpty => string.IsNullOrWhiteSpace(this.Text) && this.FileAttachments.Count == 0; /// /// The transcripts among the attachments, whose files the chat manages itself. /// [JsonIgnore] public IReadOnlyList MediaTranscripts => this.FileAttachments.OfType().ToList(); /// /// Replaces the text of the message. /// /// The text as the user wrote it. public void SetText(string? text) { lock (this.stateLock) this.Text = text ?? string.Empty; } /// /// Takes over the attachments the composer shows. /// /// /// 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. /// /// The attachments the composer shows. public void MirrorAttachments(IEnumerable composerAttachments) { lock (this.stateLock) this.FileAttachments = Combine(composerAttachments, this.MediaTranscripts); } /// /// Adds attachments the draft does not hold yet. /// /// The attachments to add. One whose path the draft already holds is left out. /// True when at least one attachment was added. public bool AddAttachments(IEnumerable attachments) { lock (this.stateLock) { var combined = Combine(this.FileAttachments, attachments); if (combined.Count == this.FileAttachments.Count) return false; this.FileAttachments = combined; return true; } } /// /// Removes attachments, which is what the user does by taking them out of the composer. /// /// The attachments to remove. They are found by their path, so a newer state of the same file is removed as well. public void RemoveAttachments(IEnumerable 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(); } /// /// Replaces every attachment with what the given function makes of it. /// /// /// Meant for files which changed their place, e.g., because the chat moved into another workspace /// or was copied, together with its transcripts. /// /// Gives the attachment to keep in place of the given one, or the given one itself when nothing changes. public void ReplaceAttachments(Func replace) { lock (this.stateLock) this.FileAttachments = this.FileAttachments.Select(replace).ToList(); } /// /// Makes a sent message the draft again, which is what editing a message does. /// /// /// 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. /// /// The message to edit. public void RestoreFromMessage(ContentText message) { lock (this.stateLock) { this.Text = message.Text; this.FileAttachments = Combine(message.FileAttachments, this.MediaTranscripts); } } /// /// Empties the draft, which is what sending it does: its attachments belong to the new message from then on. /// public void Clear() { lock (this.stateLock) { this.Text = string.Empty; this.FileAttachments = []; } } /// /// Puts two lists of attachments together, keeping the first attachment of every path. /// private static List Combine(IEnumerable first, IEnumerable second) { var paths = new HashSet(StringComparer.Ordinal); return first.Concat(second).Where(attachment => paths.Add(attachment.FilePath)).ToList(); } }