using System.Text.Json; using AIStudio.Chat; namespace AIStudio.Assistants.BeyondFactsLab; public sealed partial class BeyondFactsLabStore { /// /// The file which holds a chat of the lab. /// private const string THREAD_FILE_NAME = "thread.json"; /// /// The folder of the conversation about whether the lab fits a task. It is no ID, so no preparation can have it. /// private const string ONBOARDING_DIRECTORY_NAME = "onboarding"; /// /// Guards the conversation about whether the lab fits a task, which belongs to no preparation. /// private readonly SemaphoreSlim onboardingLock = new(1, 1); /// /// Loads a chat of the lab. /// /// Where the chat belongs. /// The cancellation token. /// The chat, or null while there is none. /// This version cannot open the chat. public async Task LoadThreadAsync(BflThreadLocation location, CancellationToken token = default) { await this.InitializeAsync(token); return await this.LoadThreadWithoutInitializeAsync(location, token); } /// /// Stores a chat of the lab. The chat of a step counts as a change of its preparation. /// /// Where the chat belongs. /// The chat. /// The cancellation token. /// The preparation of the step does not exist, e.g., because the person deleted it while the model answered. /// This version cannot open the preparation or the stored chat, so it does not write them either. public async Task SaveThreadAsync(BflThreadLocation location, ChatThread thread, CancellationToken token = default) { if (thread.ChatId != location.ChatId) throw new ArgumentException("The chat belongs to another place.", nameof(thread)); await this.InitializeAsync(token); var gate = this.ThreadLock(location); await gate.WaitAsync(token); try { var project = location is BflStepThreadLocation stepLocation ? await this.LoadRequiredWithoutInitializeAsync(stepLocation.ProjectId, token) : null; // A chat which a newer version stored, or which is damaged, stays as it is: await this.LoadThreadWithoutInitializeAsync(location, token); await WriteTextAtomicAsync(this.ThreadPath(location), JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), token); if (project is null) return; project.ModifiedAtUtc = DateTimeOffset.UtcNow; await this.StoreProjectAsync(project, token); } finally { gate.Release(); } } /// /// Deletes a chat of the lab, so that its conversation starts over. /// /// /// Unlike the other files of a step, a chat which this version cannot open is deleted as well: the person asked /// to start the conversation over, and nothing else refers to it. /// /// Where the chat belongs. /// The cancellation token. public async Task DeleteThreadAsync(BflThreadLocation location, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.ThreadLock(location); await gate.WaitAsync(token); try { var path = this.ThreadPath(location); if (File.Exists(path)) File.Delete(path); } finally { gate.Release(); } } /// /// Finds every chat of the lab on the disk, from the folders alone. /// /// /// For the cleanup of the provenance store at the start, which must know every chat which may refer to an entry, /// also when the lab is switched off. A preparation this version cannot open keeps its chats as well. /// /// The place of each chat, by the ID of the chat. public IReadOnlyDictionary ReadAllThreadLocations() { var locations = new Dictionary(); if (!Directory.Exists(this.RootDirectory)) return locations; AddWhenStored(BflOnboardingThreadLocation.INSTANCE); foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory)) { if (!Guid.TryParse(Path.GetFileName(directory), out var projectId)) continue; foreach (var step in BflStep.All) AddWhenStored(new BflStepThreadLocation(projectId, step)); } return locations; void AddWhenStored(BflThreadLocation location) { if (File.Exists(this.ThreadPath(location))) locations[location.ChatId] = location; } } /// /// Tells when a chat of the lab was last written. /// /// Where the chat belongs. /// The time, or null when there is no chat. public DateTimeOffset? GetThreadWrittenAt(BflThreadLocation location) { var path = this.ThreadPath(location); return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path)) : null; } /// /// Reads a chat of the lab without the first start of the store. /// /// Where the chat belongs. /// The cancellation token. /// The chat, or null when there is no file. /// This version cannot open the chat. private async Task LoadThreadWithoutInitializeAsync(BflThreadLocation location, CancellationToken token) { var path = this.ThreadPath(location); if (!File.Exists(path)) return null; ChatThread? thread; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); thread = await JsonSerializer.DeserializeAsync(stream, WorkspaceBehaviour.JSON_OPTIONS, token); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { throw new InvalidDataException("This version of AI Studio cannot open the chat.", exception); } // A chat copied into another place would mix up what the provenance store keeps for it: if (thread is null || thread.ChatId != location.ChatId || IsNull(thread.Blocks)) throw new InvalidDataException("This version of AI Studio cannot open the chat."); return thread; } /// /// Gets the file of a chat of the lab. /// /// Where the chat belongs. private string ThreadPath(BflThreadLocation location) => location switch { BflStepThreadLocation stepLocation => Path.Combine(this.ProjectDirectory(stepLocation.ProjectId), STEPS_DIRECTORY_NAME, stepLocation.Step.FolderName, THREAD_FILE_NAME), BflOnboardingThreadLocation => Path.Combine(this.RootDirectory, ONBOARDING_DIRECTORY_NAME, THREAD_FILE_NAME), _ => throw new ArgumentOutOfRangeException(nameof(location), location, "The lab keeps no chat there.") }; /// /// Gets the lock which guards a chat of the lab: the chat of a step shares the lock of its preparation. /// /// Where the chat belongs. private SemaphoreSlim ThreadLock(BflThreadLocation location) => location switch { BflStepThreadLocation stepLocation => this.GetLock(stepLocation.ProjectId), BflOnboardingThreadLocation => this.onboardingLock, _ => throw new ArgumentOutOfRangeException(nameof(location), location, "The lab keeps no chat there.") }; }