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 }