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();
}
}