namespace AIStudio.Chat;
///
/// Stores a chat shortly after the user changed its draft, and once more when the user leaves it.
///
///
/// 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.
///
/// 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.
///
/// 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.
///
/// Storing always runs on the thread pool, never on the renderer, because it writes the whole
/// conversation to the disk.
///
/// How long the user has to pause before the chat is stored.
/// Whether the chat is stored on the disk.
/// Whether an answer is being written for the chat right now.
/// Stores the chat, and answers false when that was skipped.
/// Where failures are reported.
public sealed class ChatDraftAutosave(TimeSpan delay, Func isStored, Func isBusy, Func> storeAsync, ILogger logger) : IAsyncDisposable
{
private readonly Lock stateLock = new();
private Timer? timer;
private ChatThread? pendingChat;
private bool isDisposed;
///
/// Notes that the user changed the draft of the given chat, which gets stored once the user paused.
///
/// The chat as the composer shows it now.
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");
}
///
/// Swaps the noted chat for a newer copy of the same chat.
///
///
/// 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.
///
/// The copy which replaced the thread.
public void Follow(ChatThread chat)
{
lock (this.stateLock)
{
if (this.pendingChat?.ChatId == chat.ChatId)
this.pendingChat = chat;
}
}
///
/// Stores the noted chat right away, which is what leaving the chat calls for.
///
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 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
///
/// Stores what is still noted, and stops the timer.
///
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
}