From cb54ad297dc9d99d0fcbdc780aa23d7de34f18e3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 15 Nov 2026 10:32:46 +0100 Subject: [PATCH] Moved the chats of the BeyondFacts Lab to the embedded chat of assistants (#1203) --- .../Assistants/BeyondFactsLab/BflChat.razor | 94 +---- .../BeyondFactsLab/BflChat.razor.cs | 250 ++----------- .../Assistants/BeyondFactsLab/BflChatStore.cs | 38 ++ .../BeyondFactsLab/BflConversation.cs | 38 -- .../BeyondFactsLab/BflConversationService.cs | 334 +++--------------- .../BeyondFactsLab/BflMaterialBudget.cs | 3 +- .../BeyondFactsLab/BflTokenBudget.cs | 40 --- .../ModelComparisonSeriesChatStore.cs | 3 + .../ModelComparisonSeriesView.razor.cs | 8 +- .../Chat/Embedded/EmbeddedChatOpening.cs | 16 +- .../Chat/Embedded/EmbeddedChatPreparation.cs | 38 ++ .../Chat/Embedded/EmbeddedChatRequest.cs | 11 +- .../Chat/Embedded/EmbeddedChatService.cs | 39 +- .../Chat/Embedded/EmbeddedChatTokenBudget.cs | 39 ++ .../Embedded/EmbeddedChatTokenBudgetState.cs} | 10 +- .../Chat/Embedded/IEmbeddedChatStore.cs | 7 + .../Components/EmbeddedChat.razor | 36 +- .../Components/EmbeddedChat.razor.cs | 195 +++++++++- .../BeyondFactsLab/BflChatRequestTests.cs | 165 +++++++++ app/Tests/BeyondFactsLab/BflChatStoreTests.cs | 147 ++++++++ .../BeyondFactsLab/BflMaterialBudgetTests.cs | 5 +- .../BeyondFactsLab/BflPromptOverridesTests.cs | 2 +- .../BeyondFactsLab/BflTokenBudgetTests.cs | 49 --- .../Chat/Embedded/EmbeddedChatOpeningTests.cs | 55 ++- .../Chat/Embedded/EmbeddedChatServiceTests.cs | 82 ++++- .../Embedded/EmbeddedChatTokenBudgetTests.cs | 49 +++ .../Embedded/InMemoryEmbeddedChatStore.cs | 13 + 27 files changed, 1023 insertions(+), 743 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChatStore.cs delete mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversation.cs delete mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudget.cs create mode 100644 app/MindWork AI Studio/Chat/Embedded/EmbeddedChatPreparation.cs create mode 100644 app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudget.cs rename app/MindWork AI Studio/{Assistants/BeyondFactsLab/BflTokenBudgetState.cs => Chat/Embedded/EmbeddedChatTokenBudgetState.cs} (59%) create mode 100644 app/Tests/BeyondFactsLab/BflChatRequestTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflChatStoreTests.cs delete mode 100644 app/Tests/BeyondFactsLab/BflTokenBudgetTests.cs create mode 100644 app/Tests/Chat/Embedded/EmbeddedChatTokenBudgetTests.cs diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor index 0938a48ff..21375d177 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor @@ -1,79 +1,19 @@ -@using AIStudio.Chat @inherits MSGComponentBase -@if (this.ShowHeading) -{ - @T("Conversation") -} -@if (this.conversation is null) -{ - -} -else if (this.conversation.Thread is not { } thread) -{ - - @TA("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it. You can start the conversation over, which deletes it.") - - @T("Start over") -} -else -{ - @if (!this.conversation.HasVisibleMessages && !this.IsRunning) - { - @this.Introduction -
- - @T("Start the conversation") - -
- } - else - { - @foreach (var block in thread.Blocks) - { - @if (block is { HideFromUser: false, Content: not null }) - { - - } - } - } - - @if (this.ProviderIssue is { } issue) - { - @issue - } - - @* Laid out like the input of the chat page: sending through the icon in the field, and the tokens as its helper text. *@ - - - @if (this.IsRunning || this.conversation.HasVisibleMessages) - { - - @if (this.IsRunning) - { - @T("Stop") - } - @if (this.conversation.HasVisibleMessages) - { - @T("Start over") - } - - } -} \ No newline at end of file + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor.cs index d2f4c95bf..010ad7d04 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChat.razor.cs @@ -1,13 +1,8 @@ using AIStudio.Chat; +using AIStudio.Chat.Embedded; using AIStudio.Components; -using AIStudio.Dialogs; -using AIStudio.Settings.DataModel; -using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; - -using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.BeyondFactsLab; @@ -15,9 +10,11 @@ namespace AIStudio.Assistants.BeyondFactsLab; /// A chat of the BeyondFacts Lab, such as the one of a step below its form "Your details". /// /// -/// The chat itself lives in BflConversationService, so an answer goes on while the person looks elsewhere; this -/// component shows it and renders whenever the service says it changed. It offers no "send to": the lab neither -/// receives content from other assistants nor hands its answers on. +/// The embedded chat of assistants with what the lab decides for each of its places: the opening request, the +/// introduction, the question before starting over, and, for every request, the system prompt with the guide, the +/// details, and the material, see BflConversationService.PrepareAsync. The count of tokens shows how full the context +/// of the model is, since every request carries the guide. It offers no "send to": the lab neither receives content +/// from other assistants nor hands its answers on. /// public partial class BflChat : MSGComponentBase { @@ -51,67 +48,24 @@ public partial class BflChat : MSGComponentBase [Inject] private BflMaterialService MaterialService { get; init; } = null!; - [Inject] - private IDialogService DialogService { get; init; } = null!; - [Inject] private ILogger Logger { get; init; } = null!; - [Inject] - private ConversationTokenCounter TokenCounter { get; init; } = null!; + /// + /// Readies a request of the chat shown, with the provider chosen; made anew whenever either changes. + /// + private Func> prepareAsync = _ => Task.FromResult(EmbeddedChatPreparation.REFUSED); /// - /// Ends a count when the component goes away. + /// Gets the system prompt of the next request of the chat shown, for the count of tokens; made anew whenever the + /// chat or the provider changes. /// - private readonly CancellationTokenSource countCancellation = new(); + private Func> systemPromptForCountAsync = () => Task.FromResult(null); /// - /// The token of countCancellation, taken while the source still lives. + /// Changes whenever the material of the preparation changes, since every request of a step carries it. /// - /// - /// A count goes on after an await, when the component may have gone away meanwhile, e.g., because the person moved - /// on to another step. The source is disposed by then and refuses to hand out its token; the token taken before - /// still tells that the count was cancelled. - /// - private CancellationToken countToken; - - /// - /// What the next request carries, as far as it was counted. - /// - private ConversationTokens tokens = ConversationTokens.UNAVAILABLE; - - /// - /// The provider the tokens were counted for, to count again when the parent chooses another one. - /// - private string countedProviderId = string.Empty; - - /// - /// The chat shown, or null while it loads. - /// - private BflConversation? conversation; - - /// - /// Where the chat shown belongs, to notice when the parent shows another one. - /// - private BflThreadLocation? shownLocation; - - /// - /// The message the person writes. - /// - private string userInput = string.Empty; - - /// - /// The attributes of the message field, such as the spell checking the person chose. - /// - private readonly Dictionary inputAttributes = new(); - - private bool IsRunning => this.conversation?.IsRunning ?? false; - - private BflTokenBudgetState Budget => BflTokenBudget.Of(this.tokens); - - private bool CanSend => this.conversation is { IsAvailable: true, IsRunning: false } && this.ProviderIssue is null && this.Budget is not BflTokenBudgetState.FULL; - - private bool CanSendMessage => this.CanSend && !string.IsNullOrWhiteSpace(this.userInput); + private int materialsVersion; /// /// Gets what the chat says before its conversation begins. @@ -143,10 +97,7 @@ public partial class BflChat : MSGComponentBase /// protected override async Task OnInitializedAsync() { - this.countToken = this.countCancellation.Token; - this.ConversationService.ConversationChanged += this.OnConversationChangedAsync; this.MaterialService.MaterialsChanged += this.OnMaterialsChangedAsync; - this.SettingsManager.InjectSpellchecking(this.inputAttributes); await base.OnInitializedAsync(); } @@ -154,48 +105,28 @@ public partial class BflChat : MSGComponentBase protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); - if (this.shownLocation != this.Location) - { - this.shownLocation = this.Location; - this.conversation = null; - this.userInput = string.Empty; - this.conversation = await this.ConversationService.GetAsync(this.Location); - await this.RecountAsync(); - } - else if (this.countedProviderId != this.Provider.Id) - await this.RecountAsync(); + + // Bound to the chat and the provider shown now, so that a request never takes those of the next render: + var location = this.Location; + var provider = this.Provider; + this.prepareAsync = thread => this.ConversationService.PrepareAsync(location, provider, thread); + this.systemPromptForCountAsync = () => this.SystemPromptForCountAsync(location, provider); } /// protected override void DisposeResources() { - this.ConversationService.ConversationChanged -= this.OnConversationChangedAsync; this.MaterialService.MaterialsChanged -= this.OnMaterialsChangedAsync; - this.countCancellation.Cancel(); - this.countCancellation.Dispose(); base.DisposeResources(); } /// - /// Renders the chat again when the service changed it, and counts again once an answer is complete. + /// Creates the chat of the place shown, for a place which has no stored chat yet. /// - /// Where the chat which changed belongs. - private Task OnConversationChangedAsync(BflThreadLocation location) - { - if (location != this.Location) - return Task.CompletedTask; - - return this.InvokeAsync(async () => - { - if (!this.IsRunning) - await this.RecountAsync(); - - this.StateHasChanged(); - }); - } + private ChatThread CreateThread() => BflThreadFactory.Create(this.Location); /// - /// Counts again when the material of the preparation changed, since every request of a step carries it. + /// Lets the chat count again when the material of the preparation changed. /// /// The ID of the preparation whose material changed. private Task OnMaterialsChangedAsync(Guid projectId) @@ -203,145 +134,30 @@ public partial class BflChat : MSGComponentBase if (this.Location is not BflStepThreadLocation stepLocation || stepLocation.ProjectId != projectId) return Task.CompletedTask; - return this.InvokeAsync(async () => + return this.InvokeAsync(() => { - if (!this.IsRunning) - await this.RecountAsync(); - + this.materialsVersion++; this.StateHasChanged(); }); } /// - /// Counts what the next request of the chat carries: the system prompt, e.g., with the guide, and the conversation. + /// Puts together the system prompt of the next request of a chat, for the count of tokens. /// - /// - /// Only while no answer runs: the request appends to the conversation which the count reads. The message being - /// written is left out; the reserve of the budget keeps room for it. - /// - private async Task RecountAsync() + /// Where the chat belongs. + /// The provider which answers. + /// The system prompt, or null when it cannot be put together now. + private async Task SystemPromptForCountAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) { - this.countedProviderId = this.Provider.Id; - if (this.conversation is not { Thread: { } thread, IsRunning: false } || this.ProviderIssue is not null) - { - this.tokens = ConversationTokens.UNAVAILABLE; - return; - } - try { - var systemPrompt = await this.ConversationService.ComposeSystemPromptAsync(this.Location, this.Provider); - var parts = ConversationParts.Of(thread, systemPrompt, string.Empty, null, false, null); - this.tokens = await this.TokenCounter.CountAsync(this.Provider, parts, thread.ReportedHistoryFor(this.Provider.Model), this.countToken); - } - catch (OperationCanceledException) - { - // The component went away. + return await this.ConversationService.ComposeSystemPromptAsync(location, provider); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException) { // Sending reports the same problem; the count only stays unknown: this.Logger.LogDebug(exception, "Could not count the tokens of a chat in the BeyondFacts Lab."); - this.tokens = ConversationTokens.UNAVAILABLE; - } - } - - /// - /// Gets how full the context of the model is, or null when nothing could be counted. - /// - private string? TokenBudgetText - { - get - { - if (!this.tokens.IsKnown) - return null; - - var culture = Tools.PluginSystem.I18N.I.Culture; - var counted = TokenAmount.Format(this.tokens.Tokens, culture); - if (!this.tokens.Window.IsKnown) - return string.Format(T("approx. {0} tokens; the context window of this model is unknown"), counted); - - var window = TokenAmount.Format(this.tokens.Window.DefaultTokens, culture); - var budget = string.Format(this.tokens.IsEstimate && !this.tokens.HistoryIsReported ? T("approx. {0} of {1} tokens") : T("{0} of {1} tokens"), counted, window); - return this.Budget switch - { - BflTokenBudgetState.FULL => $"{budget}. {TA("This conversation fills the context window of the model. Start it over, or choose a provider whose model reads more at once.")}", - BflTokenBudgetState.WARNING => $"{budget}. {TA("This conversation fills most of the context window of the model. Soon, you will have to start it over.")}", - _ => budget - }; - } - } - - /// - /// Gets the classes of the message field; like on the chat page, they colour its helper text with the tokens. - /// - private string InputClass => this.Budget switch - { - BflTokenBudgetState.FULL => "mt-2 token-budget-exceeded", - BflTokenBudgetState.WARNING => "mt-2 token-budget-nearly-full", - _ => "mt-2" - }; - - /// - /// Lets the model open the conversation. - /// - private async Task StartAsync() - { - if (this.CanSend) - await this.ConversationService.StartAsync(this.Location, this.Provider); - } - - /// - /// Sends the message of the person. - /// - private async Task SendAsync() - { - if (!this.CanSendMessage) - return; - - var text = this.userInput.Trim(); - this.userInput = string.Empty; - await this.ConversationService.SendAsync(this.Location, this.Provider, text); - } - - /// - /// Sends the message when the person releases the shortcut they chose for the chat. - /// - /// The key released. - private async Task InputKeyEventAsync(KeyboardEventArgs keyEvent) - { - if (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior.IsSendShortcut(keyEvent)) - await this.SendAsync(); - } - - /// - /// Stops the answer the model writes. - /// - private Task CancelAsync() => this.ConversationService.CancelAsync(this.Location); - - /// - /// Deletes the conversation after asking, so that it starts over. - /// - private async Task ResetAsync() - { - var parameters = new DialogParameters - { - { dialog => dialog.Message, this.ResetQuestion } - }; - - var reference = await this.DialogService.ShowAsync(T("Start over"), parameters, DialogOptions.FULLSCREEN); - var result = await reference.Result; - if (result is null || result.Canceled) - return; - - try - { - await this.ConversationService.ResetAsync(this.Location); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - this.Logger.LogError(exception, "Could not delete the chat '{Location}' of the BeyondFacts Lab.", this.Location.Key); - await this.MessageBus.SendError(new(AppIcons.DELETE, TA("AI Studio could not delete this conversation. Please try again."))); + return null; } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChatStore.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChatStore.cs new file mode 100644 index 000000000..d52ee198e --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflChatStore.cs @@ -0,0 +1,38 @@ +using AIStudio.Chat; +using AIStudio.Chat.Embedded; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Lets the embedded chat of a place of the BeyondFacts Lab live where the lab keeps it, e.g., in the folder of a step +/// of a preparation. +/// +/// +/// The ID of a chat of the lab follows from its place, see BflThreadId, so the store serves the one chat of its place +/// and refuses any other. Files, paths, and locks stay those of BeyondFactsLabStore, so chats stored before keep +/// working, and the cleanup of the provenance store still finds every chat, see +/// BeyondFactsLabStore.ReadAllThreadLocations. +/// +/// The store of the lab. +/// The place of the chat. +public sealed class BflChatStore(BeyondFactsLabStore store, BflThreadLocation location) : IEmbeddedChatStore +{ + /// + public Task LoadThreadAsync(Guid chatId, CancellationToken token = default) => store.LoadThreadAsync(this.LocationOf(chatId), token); + + /// + public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default) => store.SaveThreadAsync(this.LocationOf(thread.ChatId), thread, token); + + /// + public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default) => store.DeleteThreadAsync(this.LocationOf(chatId), token); + + /// + /// Gets the place of a chat, which must be the place of this store. + /// + /// The ID of the chat. + /// The place of the store. + /// The chat belongs to another place. + private BflThreadLocation LocationOf(Guid chatId) => chatId == location.ChatId + ? location + : throw new ArgumentException("The chat belongs to another place of the lab.", nameof(chatId)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversation.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversation.cs deleted file mode 100644 index 939f49321..000000000 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversation.cs +++ /dev/null @@ -1,38 +0,0 @@ -using AIStudio.Chat; - -namespace AIStudio.Assistants.BeyondFactsLab; - -/// -/// A chat of the BeyondFacts Lab, as BflConversationService keeps it while the app runs. -/// -/// -/// The service keeps it beyond the page, so that an answer goes on while the person looks at another page, and the -/// page shows it again when it comes back. -/// -public sealed class BflConversation -{ - /// - /// Gets where the chat belongs, e.g., to a step of a preparation. - /// - public required BflThreadLocation Location { get; init; } - - /// - /// Gets or sets the chat, or null when this version cannot open the stored one. - /// - public ChatThread? Thread { get; set; } - - /// - /// Gets or sets whether the model answers right now. - /// - public bool IsRunning { get; set; } - - /// - /// Gets whether the chat can be shown and continued. - /// - public bool IsAvailable => this.Thread is not null; - - /// - /// Gets whether the chat has a message the person sees. - /// - public bool HasVisibleMessages => this.Thread?.Blocks.Any(block => !block.HideFromUser) ?? false; -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs index b69edc355..d1e58b4ba 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs @@ -1,28 +1,30 @@ -using System.Collections.Concurrent; - using AIStudio.Chat; +using AIStudio.Chat.Embedded; using AIStudio.Provider; using AIStudio.Settings; -using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.ToolCallingSystem; namespace AIStudio.Assistants.BeyondFactsLab; /// -/// Runs the chats of the BeyondFacts Lab, such as those of the steps of a preparation. +/// Opens the chats of the BeyondFacts Lab, such as those of the steps of a preparation, and puts together what each of +/// their requests carries. /// /// -/// A singleton, so that an answer goes on while the person looks at another page: the page asks for the chat again -/// when it comes back and listens to ConversationChanged. Each request registers in AssistantSessionService, which -/// shows the work in the navigation. The service does nothing until the page of the lab asks for a chat. +/// The chats themselves run in EmbeddedChatService, like the chats of other assistants: it keeps them while the app +/// runs, so that an answer goes on while the person looks at another page, and registers each request in +/// AssistantSessionService. A chat lives where the lab keeps it, see BflChatStore. What the lab decides for every +/// request comes from here, see PrepareAsync: the system prompt with the guide, the details, and the material, the +/// images of the material, the tools, and where the chat may send data. The service does nothing until the page of the +/// lab asks for a chat. /// /// The store of the lab. +/// The service which runs the embedded chats. /// The settings. -/// The registry of running assistant sessions. /// The message bus, for errors. /// The registry of the tools, which tells the chats what they can do on the web. /// The logger. -public sealed class BflConversationService(BeyondFactsLabStore store, SettingsManager settingsManager, AssistantSessionService sessionService, MessageBus messageBus, ToolRegistry toolRegistry, ILogger logger) +public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedChatService chatService, SettingsManager settingsManager, MessageBus messageBus, ToolRegistry toolRegistry, ILogger logger) { /// /// The request which lets the model open the conversation of a step. The person does not see it. @@ -38,41 +40,13 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa /// public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt."; - /// - /// How often a streamed answer renders at most. - /// - private static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100); - private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService)); - /// - /// The chats loaded so far, by their places. - /// - private readonly ConcurrentDictionary conversations = new(); - - /// - /// Serializes loading a chat, so that two pages never load the same chat twice. - /// - private readonly SemaphoreSlim loadLock = new(1, 1); - - /// - /// Guards whether a chat answers right now, so that two requests never run in the same chat. - /// - private readonly Lock runningLock = new(); - /// /// Gets the folder whose files replace the prompts of the lab, or null while the person does not allow it. The /// setting counts anew for every request, so that switching it on or off shows in the next answer. /// - internal string? PromptOverridesDirectory =>BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory); - - /// - /// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request. - /// - /// - /// Called on the thread of the request. A page renders through InvokeAsync. - /// - public event Func? ConversationChanged; + internal string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory); /// /// Gets a chat, loading it on the first call. @@ -80,72 +54,52 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa /// Where the chat belongs. /// The cancellation token. /// The chat; it is not available when this version cannot open the stored one. - public async Task GetAsync(BflThreadLocation location, CancellationToken token = default) - { - if (this.conversations.TryGetValue(location, out var conversation)) - return conversation; - - await this.loadLock.WaitAsync(token); - try - { - if (this.conversations.TryGetValue(location, out conversation)) - return conversation; - - ChatThread? thread; - try - { - thread = await store.LoadThreadAsync(location, token) ?? BflThreadFactory.Create(location); - } - catch (InvalidDataException exception) - { - // The file stays as it is, see BeyondFactsLabStore.SaveThreadAsync: - logger.LogWarning(exception, "Could not open the chat '{Location}' of the BeyondFacts Lab.", location.Key); - thread = null; - } - - conversation = new BflConversation { Location = location, Thread = thread }; - this.conversations[location] = conversation; - return conversation; - } - finally - { - this.loadLock.Release(); - } - } + public Task GetAsync(BflThreadLocation location, CancellationToken token = default) => chatService.GetAsync(this.StoreOf(location), Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location), token); /// - /// Lets the model open a conversation, as its prompt describes. + /// Gets where a chat of the lab lives. /// /// Where the chat belongs. - /// The provider which answers. - public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OpeningRequestFor(location), isHidden: true); + /// The store, which serves this chat only. + public IEmbeddedChatStore StoreOf(BflThreadLocation location) => new BflChatStore(store, location); /// - /// Gets the request which lets the model open a conversation. + /// Gets the request which lets the model open a conversation, see EmbeddedChat.StartRequest. The person does not see + /// it. /// /// Where the chat belongs. /// The request, which names the section of the prompt that describes the first message. public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST; /// - /// Sends a message of the person and lets the model answer it. + /// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it. + /// + /// The ID of the preparation. + /// The IDs of the chats the preparation could have had. + public async Task> ForgetProjectAsync(Guid projectId) + { + List chatIds = [.. BflStep.All.Select(step => new BflStepThreadLocation(projectId, step).ChatId)]; + foreach (var chatId in chatIds) + await chatService.ForgetAsync(chatId); + + return chatIds; + } + + /// + /// Readies a chat of the lab for a request, see EmbeddedChatRequest.PrepareAsync: checks the provider and puts + /// together what the request carries. /// /// - /// The message is stored before the model answers, so that a crash loses at most the answer. What goes wrong is - /// reported through the message bus; the method itself does not fail. + /// What goes wrong is reported through the message bus; the request then does not go out. /// /// Where the chat belongs. /// The provider which answers. - /// The message. - /// Whether the person does not see the message, e.g., the request which opens a conversation. - public async Task SendAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, string text, bool isHidden = false) + /// The chat. + /// What the request carries, or EmbeddedChatPreparation.REFUSED. + public async Task PrepareAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread) { - if (string.IsNullOrWhiteSpace(text)) - return; - - var conversation = await this.GetAsync(location); - if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(location, thread, provider)) - return; + if (!await this.CheckProviderAsync(location, thread, provider)) + return EmbeddedChatPreparation.REFUSED; BflRequestMaterial requestMaterial; try @@ -155,139 +109,35 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa catch (BflPromptOverrideException exception) { logger.LogWarning(exception, "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill."); - await messageBus.SendError(new(AppIcons.ERROR, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."),exception.FileName, exception.Placeholder))); - return; + await messageBus.SendError(new(AppIcons.ERROR, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."), exception.FileName, exception.Placeholder))); + return EmbeddedChatPreparation.REFUSED; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException) { logger.LogError(exception, "The BeyondFacts Lab could not put together the system prompt of the chat '{Location}'.", location.Key); await messageBus.SendError(new(AppIcons.ERROR, TA("The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug."))); - return; + return EmbeddedChatPreparation.REFUSED; } - lock (this.runningLock) - { - if (conversation.IsRunning) - return; - - conversation.IsRunning = true; - } - - ContentBlock? answerBlock = null; - var sessionKey = SessionKey(location); - var sessionId = Guid.Empty; - var status = AssistantSessionStatus.FAILED; - try - { - // A chat stored before the lab offered tools gets its restriction now, before it may read a page: - BflChatTools.Restrict(thread, location); - BflThreadFactory.PrepareForRequest(thread, provider, requestMaterial.SystemPrompt, BflChatTools.For(location)); - var request = new ContentText { Text = text }; - thread.Blocks.Add(new ContentBlock - { - Time = DateTimeOffset.Now, - ContentType = ContentType.TEXT, - Role = ChatRole.USER, - Content = request, - HideFromUser = isHidden - }); - - await store.SaveThreadAsync(location, thread); - - var answer = new ContentText { InitialRemoteWait = true }; - answerBlock = new ContentBlock - { - Time = DateTimeOffset.Now, - ContentType = ContentType.TEXT, - Role = ChatRole.AI, - Content = answer - }; - - var lastRendering = DateTimeOffset.MinValue; - answer.StreamingEvent = async () => - { - var now = DateTimeOffset.Now; - if (now - lastRendering < STREAMING_RENDER_MIN_TIME) - return; - - lastRendering = now; - await this.NotifyAsync(location); - }; - - answer.StreamingDone = () => this.NotifyAsync(location); - thread.Blocks.Add(answerBlock); - - var cancellation = new CancellationTokenSource(); - sessionId = (await sessionService.TryBeginAsync(sessionKey, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.Name, cancellation, thread, new(StringComparer.Ordinal))).SessionId; - await this.NotifyAsync(location); - - await answer.CreateFromProviderAsync(provider.CreateProvider(), provider.Model, request, WithImages(thread, requestMaterial.Images), cancellation.Token); - status = cancellation.IsCancellationRequested ? AssistantSessionStatus.CANCELED : AssistantSessionStatus.COMPLETED; - } - catch (OperationCanceledException) - { - status = AssistantSessionStatus.CANCELED; - } - catch (ProviderRequestException exception) - { - logger.LogError(exception, "The provider request of the BeyondFacts Lab failed. Status={StatusCode}, Reason='{ReasonPhrase}'.", exception.StatusCode, exception.ReasonPhrase); - await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, exception.UserMessage)); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) - { - logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", location.Key); - await messageBus.SendError(new(AppIcons.SAVE_FAILED, TA("AI Studio could not save this conversation. Please try again."))); - } - finally - { - await this.FinishAsync(conversation, thread, answerBlock, sessionKey, sessionId, status); - } + return Prepare(location, provider, thread, requestMaterial); } /// - /// Stops the answer the model writes in a chat. + /// Readies a chat of the lab for a request with what the lab put together for it. /// /// Where the chat belongs. - public Task CancelAsync(BflThreadLocation location) => sessionService.CancelAsync(SessionKey(location)); - - /// - /// Deletes a conversation, so that it starts over. - /// - /// Where the chat belongs. - /// False while the model answers in the chat. - public async Task ResetAsync(BflThreadLocation location) + /// The provider which answers. + /// The chat, which gets its prompt, its tools, and its restriction. + /// The system prompt and the images of the material. + /// The preparation, whose request carries the images, see WithImages. + internal static EmbeddedChatPreparation Prepare(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread, BflRequestMaterial requestMaterial) { - var conversation = await this.GetAsync(location); - lock (this.runningLock) - { - if (conversation.IsRunning) - return false; - } + // A chat stored before the lab offered tools gets its restriction now, before it may read a page: + BflChatTools.Restrict(thread, location); + BflThreadFactory.PrepareForRequest(thread, provider, requestMaterial.SystemPrompt, BflChatTools.For(location)); - await store.DeleteThreadAsync(location); - conversation.Thread = BflThreadFactory.Create(location); - - // The new conversation keeps the ID of the chat, so what the old one received would otherwise wait for the next start: - await ChatProvenance.ForgetChatsAsync([location.ChatId]); - await this.NotifyAsync(location); - return true; - } - - /// - /// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it. - /// - /// The ID of the preparation. - /// The IDs of the chats the preparation could have had. - public async Task> ForgetProjectAsync(Guid projectId) - { - var locations = BflStep.All.Select(step => new BflStepThreadLocation(projectId, step)).ToList(); - foreach (var location in locations) - { - await sessionService.CancelAsync(SessionKey(location)); - this.conversations.TryRemove(location, out _); - } - - return [.. locations.Select(location => location.ChatId)]; + var images = requestMaterial.Images; + return EmbeddedChatPreparation.READY with { ThreadForRequest = request => WithImages(request, images) }; } /// @@ -535,78 +385,4 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason)); return false; } - - /// - /// Ends a request: removes an empty answer, stores the chat, and closes the session. - /// - /// The chat. - /// The thread of the chat. - /// The block of the answer, or null when the request did not get that far. - /// The key of the session. - /// The ID of the session, or Guid.Empty when none began. - /// How the request ended. - private async Task FinishAsync(BflConversation conversation, ChatThread thread, ContentBlock? answerBlock, AssistantSessionKey sessionKey, Guid sessionId, AssistantSessionStatus status) - { - if (answerBlock is { Content: ContentText answer } && string.IsNullOrWhiteSpace(answer.Text)) - thread.Blocks.Remove(answerBlock); - - if (answerBlock is not null) - { - try - { - await store.SaveThreadAsync(conversation.Location, thread); - } - catch (FileNotFoundException) - { - // The person deleted the preparation while the model answered; nothing is left to store. - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) - { - logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", conversation.Location.Key); - await messageBus.SendError(new(AppIcons.SAVE_FAILED, TA("AI Studio could not save this conversation. Please try again."))); - } - } - - lock (this.runningLock) - conversation.IsRunning = false; - - if (sessionId != Guid.Empty) - { - await sessionService.CompleteAsync(sessionKey, sessionId, status, string.Empty, thread, new(StringComparer.Ordinal)); - - // The lab keeps the chat itself; the registry only shows that the lab is at work: - await sessionService.ClearAsync(sessionKey); - } - - await this.NotifyAsync(conversation.Location); - } - - /// - /// Tells every page which listens that a chat changed. - /// - /// Where the chat belongs. - private async Task NotifyAsync(BflThreadLocation location) - { - if (this.ConversationChanged is not { } handlers) - return; - - foreach (var handler in handlers.GetInvocationList().Cast>()) - { - try - { - await handler(location); - } - catch (Exception exception) - { - // A page which went away meanwhile must not stop the answer: - logger.LogDebug(exception, "A page of the BeyondFacts Lab could not show a change of a chat."); - } - } - } - - /// - /// Gets the key under which a request of a chat registers in AssistantSessionService. - /// - /// Where the chat belongs. - private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.Key); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMaterialBudget.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMaterialBudget.cs index 8b1318773..9897fa14a 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMaterialBudget.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMaterialBudget.cs @@ -1,3 +1,4 @@ +using AIStudio.Chat.Embedded; using AIStudio.Models; namespace AIStudio.Assistants.BeyondFactsLab; @@ -83,7 +84,7 @@ public static class BflMaterialBudget if (!contextWindow.IsKnown) return CHARACTERS_WITHOUT_A_KNOWN_CONTEXT_WINDOW; - var freeTokens = (long)contextWindow.DefaultTokens - BflTokenBudget.RESERVE_TOKENS - (long)Math.Max(0, sentImages) * TOKENS_PER_IMAGE; + var freeTokens = (long)contextWindow.DefaultTokens - EmbeddedChatTokenBudget.RESERVE_TOKENS - (long)Math.Max(0, sentImages) * TOKENS_PER_IMAGE; var freeCharacters = freeTokens * CHARACTERS_PER_TOKEN - promptCharacters; return (int)Math.Clamp((long)(freeCharacters * SHARE_OF_THE_FREE_CONTEXT), 0, MAX_CHARACTERS); } diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudget.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudget.cs deleted file mode 100644 index ee2bebc4e..000000000 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudget.cs +++ /dev/null @@ -1,40 +0,0 @@ -using AIStudio.Chat; - -namespace AIStudio.Assistants.BeyondFactsLab; - -/// -/// Decides how much room a chat of the BeyondFacts Lab has left in the context of its model. -/// -/// -/// Every request carries the guide of the lab, so a conversation fills the context faster than an ordinary chat. -/// A model which loses the beginning of its context loses its instructions first; therefore, the lab stops before. -/// -public static class BflTokenBudget -{ - /// - /// From which share of the context on the lab warns. - /// - public const double WARNING_SHARE = 0.8; - - /// - /// How many tokens the lab keeps free for the next message and its answer. - /// - public const int RESERVE_TOKENS = 4_096; - - /// - /// Decides how much room a chat has left. - /// - /// What the conversation costs, with the system prompt, and the context of the model. - /// The state of the budget. - public static BflTokenBudgetState Of(ConversationTokens tokens) - { - if (!tokens.IsKnown || !tokens.Window.IsKnown) - return BflTokenBudgetState.UNKNOWN; - - var window = tokens.Window.DefaultTokens; - if (tokens.Tokens > window - RESERVE_TOKENS) - return BflTokenBudgetState.FULL; - - return tokens.Tokens >= window * WARNING_SHARE ? BflTokenBudgetState.WARNING : BflTokenBudgetState.OK; - } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesChatStore.cs b/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesChatStore.cs index 329c06813..0619e4bc6 100644 --- a/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesChatStore.cs +++ b/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesChatStore.cs @@ -15,4 +15,7 @@ public sealed class ModelComparisonSeriesChatStore(ModelComparisonStore store, G /// public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default) => store.SaveSeriesChatAsync(seriesId, thread, token); + + /// + public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default) => store.DeleteSeriesChatAsync(seriesId, chatId, token); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesView.razor.cs b/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesView.razor.cs index 0b9b86f74..ddcee385e 100644 --- a/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesView.razor.cs +++ b/app/MindWork AI Studio/Assistants/ModelComparison/ModelComparisonSeriesView.razor.cs @@ -104,7 +104,7 @@ public partial class ModelComparisonSeriesView : MSGComponentBase /// /// Readies a request of the chat the user works in right now, with the provider of that chat. /// - private Func> prepareChat = _ => Task.FromResult(true); + private Func> prepareChat = _ => Task.FromResult(EmbeddedChatPreparation.READY); /// /// The copy of the first message of the first chat, with which the second chat starts, or null in the first chat. @@ -230,12 +230,12 @@ public partial class ModelComparisonSeriesView : MSGComponentBase /// /// The chat. /// The provider which answers in the chat. - /// Always true: whether the provider may answer, the embedded chat checks itself. - private Task PrepareAsync(ChatThread thread, AIStudio.Settings.Provider provider) + /// Always ready: whether the provider may answer, the embedded chat checks itself. + private Task PrepareAsync(ChatThread thread, AIStudio.Settings.Provider provider) { var toolIds = this.ToolRegistry.FilterToolIdsForProvider(provider, this.Series.ToolIds, thread.RequiredOutboundDataRestriction.Restriction); ModelComparisonSeriesThreads.PrepareForRequest(thread, this.Series, provider, toolIds); - return Task.FromResult(true); + return Task.FromResult(EmbeddedChatPreparation.READY); } /// diff --git a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatOpening.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatOpening.cs index 706ed85cb..18c5e852b 100644 --- a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatOpening.cs +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatOpening.cs @@ -1,7 +1,8 @@ namespace AIStudio.Chat.Embedded; /// -/// Decides when an embedded chat sends the message it opens with, see EmbeddedChat.OpeningMessage. +/// Decides how an embedded chat opens: when it sends the message it opens with, see EmbeddedChat.OpeningMessage, and +/// when it offers to let the model begin, see EmbeddedChat.StartRequest. /// public static class EmbeddedChatOpening { @@ -19,4 +20,17 @@ public static class EmbeddedChatOpening /// True when the chat sends the message now. public static bool SendsNow(ContentText? openingMessage, EmbeddedConversation? conversation, bool wasSent) => openingMessage is not null && !wasSent && conversation is { IsAvailable: true, IsRunning: false, UserMessageCount: 0 }; + + /// + /// Tells whether an embedded chat offers to let the model open its conversation. + /// + /// + /// Only before anything the user sees: once the model or the user wrote, the conversation has begun. The user may + /// always write first instead. + /// + /// The request which lets the model begin, or null without one. + /// The chat shown, or null while it loads. + /// True when the chat offers to begin. + public static bool OffersStart(string? startRequest, EmbeddedConversation? conversation) => + !string.IsNullOrWhiteSpace(startRequest) && conversation is { IsAvailable: true, IsRunning: false, HasVisibleMessages: false }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatPreparation.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatPreparation.cs new file mode 100644 index 000000000..ad9fe6cf3 --- /dev/null +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatPreparation.cs @@ -0,0 +1,38 @@ +namespace AIStudio.Chat.Embedded; + +/// +/// What an assistant decided while it readied a request of an embedded chat: whether the request goes out, and what it +/// carries beside the stored chat. +/// +/// +/// Both are decided together, since what a request carries may only be known once it was readied, e.g., which images +/// of the assistant fit beside the system prompt. A ready preparation which carries more starts from READY: +/// EmbeddedChatPreparation.READY with { ThreadForRequest = … }. +/// +public sealed record EmbeddedChatPreparation +{ + /// + /// The request goes out with the chat as it is. + /// + public static readonly EmbeddedChatPreparation READY = new() { IsReady = true }; + + /// + /// The request does not go out; whoever readied it told the user why. + /// + public static readonly EmbeddedChatPreparation REFUSED = new() { IsReady = false }; + + /// + /// Gets whether the request goes out. + /// + public bool IsReady { get; private init; } + + /// + /// Gets the chat which the request sends, e.g., with images which the stored chat never holds. The chat itself by + /// default. + /// + /// + /// Called once the message and the block of the answer are in the chat, so that the request carries both. Change a + /// copy only: the chat is stored as it is. + /// + public Func ThreadForRequest { get; init; } = thread => thread; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatRequest.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatRequest.cs index e166317ea..0af2e0ddf 100644 --- a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatRequest.cs +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatRequest.cs @@ -27,13 +27,8 @@ public sealed record EmbeddedChatRequest /// /// Readies the chat for the request before the message goes in: its system prompt, profile, tools, and data - /// sources, which a chat partly does not store. Returns false to send nothing; it tells the user why itself. + /// sources, which a chat partly does not store. Returns what the request carries beside the stored chat, or + /// EmbeddedChatPreparation.REFUSED to send nothing; it tells the user why itself. /// - public Func> PrepareAsync { get; init; } = _ => Task.FromResult(true); - - /// - /// Gets the chat which the request sends, e.g., with images which the stored chat never holds. The chat itself - /// by default. - /// - public Func ThreadForRequest { get; init; } = thread => thread; + public Func> PrepareAsync { get; init; } = _ => Task.FromResult(EmbeddedChatPreparation.READY); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatService.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatService.cs index 7e8924df6..97e1f16d0 100644 --- a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatService.cs +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatService.cs @@ -134,7 +134,8 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista var status = AssistantSessionStatus.FAILED; try { - if (!await request.PrepareAsync(thread) || !await this.CheckProviderAsync(conversation, thread, request.Provider)) + var preparation = await request.PrepareAsync(thread); + if (!preparation.IsReady || !await this.CheckProviderAsync(conversation, thread, request.Provider)) return false; thread.RuntimeComponent = conversation.Component; @@ -177,7 +178,7 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista sessionId = (await sessionService.TryBeginAsync(sessionKey, conversation.Component.Name, cancellation, thread, new(StringComparer.Ordinal))).SessionId; await this.NotifyAsync(chatId); - await this.answerAsync(request, answer, request.ThreadForRequest(thread), cancellation.Token); + await this.answerAsync(request, answer, preparation.ThreadForRequest(thread), cancellation.Token); status = cancellation.IsCancellationRequested ? AssistantSessionStatus.CANCELED : AssistantSessionStatus.COMPLETED; } catch (OperationCanceledException) @@ -214,6 +215,40 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista ? sessionService.CancelAsync(SessionKey(conversation)) : Task.CompletedTask; + /// + /// Deletes the conversation of a chat, so that it starts over, e.g., because the user asked for it. + /// + /// + /// Works for a chat which this version cannot open as well; it becomes available again. The new conversation keeps + /// the ID of the chat, so what the old one received goes now; otherwise, it would wait for the cleanup at the next + /// start. + /// + /// The ID of a chat which GetAsync loaded before. + /// Creates the thread of the new conversation; it gets the ID of the chat. + /// False while the model answers in the chat, or when it was not loaded. + /// The stored chat could not be deleted; the conversation stays as it is. + /// The stored chat could not be deleted; the conversation stays as it is. + public async Task ResetAsync(Guid chatId, Func createThread) + { + if (!this.conversations.TryGetValue(chatId, out var conversation)) + return false; + + lock (this.runningLock) + { + if (conversation.IsRunning) + return false; + } + + await conversation.Store.DeleteThreadAsync(chatId); + var thread = createThread(); + thread.ChatId = chatId; + conversation.Thread = thread; + + await ChatProvenance.ForgetChatsAsync([chatId]); + await this.NotifyAsync(chatId); + return true; + } + /// /// Stops the answer of a chat and forgets the chat, e.g., because the user deleted what it belonged to. /// diff --git a/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudget.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudget.cs new file mode 100644 index 000000000..5d7665aee --- /dev/null +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudget.cs @@ -0,0 +1,39 @@ +namespace AIStudio.Chat.Embedded; + +/// +/// Decides how much room an embedded chat has left in the context of its model, see EmbeddedChat.ShowsContextWindow. +/// +/// +/// Meant for assistants whose requests carry long instructions, such as the guide of the BeyondFacts Lab: their +/// conversations fill the context faster than an ordinary chat, and a model which loses the beginning of its context +/// loses its instructions first. Therefore, the chat stops before. +/// +public static class EmbeddedChatTokenBudget +{ + /// + /// From which share of the context on the chat warns. + /// + public const double WARNING_SHARE = 0.8; + + /// + /// How many tokens the chat keeps free for the next message and its answer. + /// + public const int RESERVE_TOKENS = 4_096; + + /// + /// Decides how much room a chat has left. + /// + /// What the conversation costs, with the system prompt, and the context of the model. + /// The state of the budget. + public static EmbeddedChatTokenBudgetState Of(ConversationTokens tokens) + { + if (!tokens.IsKnown || !tokens.Window.IsKnown) + return EmbeddedChatTokenBudgetState.UNKNOWN; + + var window = tokens.Window.DefaultTokens; + if (tokens.Tokens > window - RESERVE_TOKENS) + return EmbeddedChatTokenBudgetState.FULL; + + return tokens.Tokens >= window * WARNING_SHARE ? EmbeddedChatTokenBudgetState.WARNING : EmbeddedChatTokenBudgetState.OK; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudgetState.cs b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudgetState.cs similarity index 59% rename from app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudgetState.cs rename to app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudgetState.cs index c788a737f..4de3c1e91 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflTokenBudgetState.cs +++ b/app/MindWork AI Studio/Chat/Embedded/EmbeddedChatTokenBudgetState.cs @@ -1,9 +1,9 @@ -namespace AIStudio.Assistants.BeyondFactsLab; +namespace AIStudio.Chat.Embedded; /// -/// How much room a chat of the BeyondFacts Lab has left in the context of its model. +/// How much room an embedded chat has left in the context of its model. /// -public enum BflTokenBudgetState +public enum EmbeddedChatTokenBudgetState { /// /// Nobody knows: the count failed, or the model has no statement about its context. @@ -16,12 +16,12 @@ public enum BflTokenBudgetState OK, /// - /// Most of the context is used; the person should think about starting over. + /// Most of the context is used; the user should think about starting over. /// WARNING, /// - /// The next message and its answer would not fit anymore, so the lab sends no more. + /// The next message and its answer would not fit anymore, so the chat sends no more. /// FULL, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Chat/Embedded/IEmbeddedChatStore.cs b/app/MindWork AI Studio/Chat/Embedded/IEmbeddedChatStore.cs index a44e34169..51df08c87 100644 --- a/app/MindWork AI Studio/Chat/Embedded/IEmbeddedChatStore.cs +++ b/app/MindWork AI Studio/Chat/Embedded/IEmbeddedChatStore.cs @@ -25,4 +25,11 @@ public interface IEmbeddedChatStore /// The cancellation token. /// What the chat belonged to was deleted meanwhile; nothing is left to store. public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default); + + /// + /// Deletes a chat, so that its conversation starts over, also one which this version cannot open. + /// + /// The ID of the chat. + /// The cancellation token. + public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EmbeddedChat.razor b/app/MindWork AI Studio/Components/EmbeddedChat.razor index cb29e8742..87bbbff41 100644 --- a/app/MindWork AI Studio/Components/EmbeddedChat.razor +++ b/app/MindWork AI Studio/Components/EmbeddedChat.razor @@ -1,15 +1,30 @@ @using AIStudio.Chat +@using AIStudio.Chat.Embedded @inherits MSGComponentBase +@if (!string.IsNullOrWhiteSpace(this.Heading)) +{ + @this.Heading +} @if (this.conversation is null) { } else if (this.conversation.Thread is not { } thread) { - - @T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.") - + @if (this.CanReset) + { + + @TA("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it. You can start the conversation over, which deletes it.") + + @T("Start over") + } + else + { + + @T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.") + + } } else { @@ -18,6 +33,15 @@ else @this.Introduction } + @if (!this.IsReadOnly && EmbeddedChatOpening.OffersStart(this.StartRequest, this.conversation)) + { +
+ + @T("Start the conversation") + +
+ } + @foreach (var block in thread.Blocks) { @if (block is { HideFromUser: false, Content: not null }) @@ -50,7 +74,7 @@ else OnKeyUp="@this.InputKeyEventAsync" UserAttributes="@this.inputAttributes" HelperText="@this.TokenText" - Class="mt-2" + Class="@this.InputClass" Disabled="@(!this.CanSend)"/> @@ -62,6 +86,10 @@ else { @T("Stop") } + @if (this.CanReset && this.conversation.HasVisibleMessages) + { + @T("Start over") + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EmbeddedChat.razor.cs b/app/MindWork AI Studio/Components/EmbeddedChat.razor.cs index d0a4dc319..c916ab9ee 100644 --- a/app/MindWork AI Studio/Components/EmbeddedChat.razor.cs +++ b/app/MindWork AI Studio/Components/EmbeddedChat.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Chat; using AIStudio.Chat.Embedded; +using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings.DataModel; using AIStudio.Tools.Rust; @@ -8,6 +9,8 @@ using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using DialogOptions = AIStudio.Dialogs.DialogOptions; + namespace AIStudio.Components; /// @@ -18,7 +21,8 @@ namespace AIStudio.Components; /// The chat itself lives in EmbeddedChatService, so an answer goes on while the user looks elsewhere; this component /// shows it and renders whenever the service says it changed. What a request carries beside the conversation, such as /// the system prompt and the tools, the assistant decides through PrepareAsync. The count of tokens leaves out the -/// context window of the model on purpose: it would tell models apart which the user compares without knowing them. +/// context window of the model by default: it would tell models apart which the user compares without knowing them. +/// An assistant whose requests carry long instructions shows it, see ShowsContextWindow. /// public partial class EmbeddedChat : MSGComponentBase { @@ -56,7 +60,7 @@ public partial class EmbeddedChat : MSGComponentBase /// Gets or sets how a request is readied before the message goes in, see EmbeddedChatRequest.PrepareAsync. /// [Parameter] - public Func> PrepareAsync { get; set; } = _ => Task.FromResult(true); + public Func> PrepareAsync { get; set; } = _ => Task.FromResult(EmbeddedChatPreparation.READY); /// /// Gets or sets whether messages to the user leave out which provider and which model answer. @@ -70,6 +74,47 @@ public partial class EmbeddedChat : MSGComponentBase [Parameter] public bool IsReadOnly { get; set; } + /// + /// Gets or sets whether the user may start the conversation over, which deletes it. A chat which this version cannot + /// open then offers that as well. + /// + [Parameter] + public bool AllowsReset { get; set; } + + /// + /// Gets or sets the question before the conversation starts over, e.g., which names what stays as it is; a general + /// question when left out. + /// + [Parameter] + public string? ResetQuestion { get; set; } + + /// + /// Gets or sets whether the count of tokens shows how much of the context window of the model the conversation + /// fills: it warns when most of it is used, and the chat sends no more when the next message and its answer would + /// not fit anymore, see EmbeddedChatTokenBudget. Leave it out where the user must not tell the models apart. + /// + [Parameter] + public bool ShowsContextWindow { get; set; } + + /// + /// Gets or sets how the count of tokens gets the system prompt of the next request, for an assistant which puts it + /// together anew for every request in PrepareAsync; null as its result means it cannot be known now, and the count + /// stays unknown. Without it, the count takes the system prompt the chat stores, which is the one of its last + /// request. + /// + /// + /// It must not fail: whatever goes wrong, sending reports anyway. + /// + [Parameter] + public Func>? SystemPromptForCountAsync { get; set; } + + /// + /// Gets or sets a number which changes whenever what the next request carries beside the conversation changed, + /// e.g., material of the assistant in its system prompt; the chat counts its tokens again then. + /// + [Parameter] + public int ContextVersion { get; set; } + /// /// Gets or sets whether the user may attach files to a message. /// @@ -94,6 +139,20 @@ public partial class EmbeddedChat : MSGComponentBase [Parameter] public ContentText? OpeningMessage { get; set; } + /// + /// Gets or sets a request which lets the model open the conversation, e.g., by asking for what it needs. While the + /// chat holds no message the user sees, it offers a button which sends the request; the user does not see the + /// request, and may write first instead. Leave OpeningMessage out with it. + /// + [Parameter] + public string? StartRequest { get; set; } + + /// + /// Gets or sets a heading above the chat, or nothing where the place of the chat names it already. + /// + [Parameter] + public string? Heading { get; set; } + /// /// Gets or sets why the provider cannot answer, or null when it can. /// @@ -118,14 +177,25 @@ public partial class EmbeddedChat : MSGComponentBase [Inject] private ConversationTokenCounter TokenCounter { get; init; } = null!; + [Inject] + private IDialogService DialogService { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + /// /// Ends a count when the component goes away. /// private readonly CancellationTokenSource countCancellation = new(); /// - /// The token of countCancellation, taken while the source still lives, see BflChat. + /// The token of countCancellation, taken while the source still lives. /// + /// + /// A count goes on after an await, when the component may have gone away meanwhile, e.g., because the user moved on + /// to another step of an assistant. The source is disposed by then and refuses to hand out its token; the token + /// taken before still tells that the count was cancelled. + /// private CancellationToken countToken; /// @@ -138,6 +208,12 @@ public partial class EmbeddedChat : MSGComponentBase /// private string countedProviderId = string.Empty; + /// + /// The version of what a request carries beside the conversation which the tokens were counted for, see + /// ContextVersion. + /// + private int countedContextVersion; + /// /// The chat shown, or null while it loads. /// @@ -170,10 +246,14 @@ public partial class EmbeddedChat : MSGComponentBase private bool IsRunning => this.conversation?.IsRunning ?? false; - private bool CanSend => this.conversation is { IsAvailable: true, IsRunning: false } && this.ProviderIssue is null; + private EmbeddedChatTokenBudgetState Budget => this.ShowsContextWindow ? EmbeddedChatTokenBudget.Of(this.tokens) : EmbeddedChatTokenBudgetState.UNKNOWN; + + private bool CanSend => this.conversation is { IsAvailable: true, IsRunning: false } && this.ProviderIssue is null && this.Budget is not EmbeddedChatTokenBudgetState.FULL; private bool CanSendMessage => this.CanSend && !string.IsNullOrWhiteSpace(this.userInput); + private bool CanReset => this.AllowsReset && !this.IsReadOnly; + #region Overrides of MSGComponentBase protected override async Task OnInitializedAsync() @@ -196,7 +276,7 @@ public partial class EmbeddedChat : MSGComponentBase this.conversation = await this.ChatService.GetAsync(this.Store, this.Component, this.ChatId, this.CreateThread); await this.RecountAsync(); } - else if (this.countedProviderId != this.Provider.Id) + else if (this.countedProviderId != this.Provider.Id || this.countedContextVersion != this.ContextVersion) await this.RecountAsync(); // Not awaited: the answer arrives through ConversationChanged, and the parameters of the next render must not wait for it. @@ -239,12 +319,14 @@ public partial class EmbeddedChat : MSGComponentBase /// Counts what the conversation carries so far, while no answer runs. /// /// - /// The system prompt counts as the chat stores it: what PrepareAsync adds for a request is not known before. The - /// count is therefore an estimate, and the message being written is left out. + /// The system prompt counts as SystemPromptForCountAsync gives it, otherwise as the chat stores it: what + /// PrepareAsync adds for a request is not known before then. The count is therefore an estimate, and the message + /// being written is left out. /// private async Task RecountAsync() { this.countedProviderId = this.Provider.Id; + this.countedContextVersion = this.ContextVersion; if (this.IsReadOnly || this.conversation is not { Thread: { } thread, IsRunning: false } || this.ProviderIssue is not null || this.Provider.UsedLLMProvider is LLMProviders.NONE) { this.tokens = ConversationTokens.UNAVAILABLE; @@ -253,7 +335,14 @@ public partial class EmbeddedChat : MSGComponentBase try { - var parts = ConversationParts.Of(thread, thread.SystemPrompt, string.Empty, null, false, null); + var systemPrompt = this.SystemPromptForCountAsync is { } systemPromptForCountAsync ? await systemPromptForCountAsync() : thread.SystemPrompt; + if (systemPrompt is null) + { + this.tokens = ConversationTokens.UNAVAILABLE; + return; + } + + var parts = ConversationParts.Of(thread, systemPrompt, string.Empty, null, false, null); this.tokens = await this.TokenCounter.CountAsync(this.Provider, parts, thread.ReportedHistoryFor(this.Provider.Model), this.countToken); } catch (OperationCanceledException) @@ -263,11 +352,58 @@ public partial class EmbeddedChat : MSGComponentBase } /// - /// Gets about how many tokens the conversation carries, or null when nothing could be counted. + /// Gets about how many tokens the conversation carries, with how full the context of the model is when the chat shows + /// it, or null when nothing could be counted. /// - private string? TokenText => this.tokens.IsKnown - ? string.Format(T("approx. {0} tokens"), TokenAmount.Format(this.tokens.Tokens, Tools.PluginSystem.I18N.I.Culture)) - : null; + private string? TokenText + { + get + { + if (!this.tokens.IsKnown) + return null; + + var culture = Tools.PluginSystem.I18N.I.Culture; + var counted = TokenAmount.Format(this.tokens.Tokens, culture); + if (!this.ShowsContextWindow) + return string.Format(T("approx. {0} tokens"), counted); + + if (!this.tokens.Window.IsKnown) + return string.Format(T("approx. {0} tokens; the context window of this model is unknown"), counted); + + var window = TokenAmount.Format(this.tokens.Window.DefaultTokens, culture); + var budget = string.Format(this.tokens.IsEstimate && !this.tokens.HistoryIsReported ? T("approx. {0} of {1} tokens") : T("{0} of {1} tokens"), counted, window); + return this.Budget switch + { + EmbeddedChatTokenBudgetState.FULL => $"{budget}. {this.FullContextText}", + EmbeddedChatTokenBudgetState.WARNING => $"{budget}. {this.NearlyFullContextText}", + _ => budget + }; + } + } + + /// + /// Gets what the user can do once the conversation fills the context of the model. + /// + private string FullContextText => this.CanReset + ? TA("This conversation fills the context window of the model. Start it over, or choose a provider whose model reads more at once.") + : TA("This conversation fills the context window of the model. Choose a provider whose model reads more at once."); + + /// + /// Gets the warning once the conversation fills most of the context of the model. + /// + private string NearlyFullContextText => this.CanReset + ? TA("This conversation fills most of the context window of the model. Soon, you will have to start it over.") + : T("This conversation fills most of the context window of the model."); + + /// + /// Gets the classes of the message field; like on the chat page, they colour its helper text with the tokens. + /// + private string InputClass => this.Budget switch + { + EmbeddedChatTokenBudgetState.FULL => "mt-2 token-budget-exceeded", + EmbeddedChatTokenBudgetState.WARNING => "mt-2 token-budget-nearly-full", + _ => "mt-2" + }; private Task AttachmentsChanged(HashSet changedAttachments) { @@ -289,6 +425,15 @@ public partial class EmbeddedChat : MSGComponentBase await this.ChatService.SendAsync(this.ChatId, this.RequestOf(message)); } + /// + /// Lets the model open the conversation, see StartRequest. + /// + private async Task StartAsync() + { + if (this.CanSend && !string.IsNullOrWhiteSpace(this.StartRequest)) + await this.ChatService.SendAsync(this.ChatId, this.RequestOf(new ContentText { Text = this.StartRequest }) with { IsHidden = true }); + } + /// /// Sends the opening message. When it cannot be sent, e.g., because the provider may not answer, the message waits /// in the field instead, so that the user can send it once the reason is gone. @@ -333,4 +478,30 @@ public partial class EmbeddedChat : MSGComponentBase /// Stops the answer the model writes. /// private Task CancelAsync() => this.ChatService.CancelAsync(this.ChatId); + + /// + /// Deletes the conversation after asking, so that it starts over. + /// + private async Task ResetAsync() + { + var parameters = new DialogParameters + { + { dialog => dialog.Message, this.ResetQuestion ?? TA("Delete this conversation and start it over?") } + }; + + var reference = await this.DialogService.ShowAsync(T("Start over"), parameters, DialogOptions.FULLSCREEN); + var result = await reference.Result; + if (result is null || result.Canceled) + return; + + try + { + await this.ChatService.ResetAsync(this.ChatId, this.CreateThread); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + this.Logger.LogError(exception, "Could not delete the embedded chat '{ChatId}'.", this.ChatId); + await this.MessageBus.SendError(new(AppIcons.DELETE, TA("AI Studio could not delete this conversation. Please try again."))); + } + } } \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflChatRequestTests.cs b/app/Tests/BeyondFactsLab/BflChatRequestTests.cs new file mode 100644 index 000000000..24dcbd16e --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflChatRequestTests.cs @@ -0,0 +1,165 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Chat; +using AIStudio.Chat.Embedded; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools; +using AIStudio.Tools.AssistantSessions; +using AIStudio.Tools.ToolCallingSystem; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks what a request of a chat of the BeyondFacts Lab carries when it runs in the embedded chat, without asking any +/// provider. +/// +[TestFixture] +public sealed class BflChatRequestTests +{ + private const AIStudio.Tools.Components LAB = AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT; + + private const string SYSTEM_PROMPT = "Der Prompt des Schritts."; + + private string dataDirectory = string.Empty; + private SettingsManager settingsManager = null!; + private ServiceProvider serviceProvider = null!; + private IServiceProvider previousServiceProvider = null!; + private AIStudio.Settings.Provider provider = null!; + + [SetUp] + public void CreateSettings() + { + this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-chat-requests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.dataDirectory); + + // The scheme which trusts Europe gives Mistral MEDIUM, the floor of the lab: + this.settingsManager = new SettingsManager(NullLogger.Instance, null!); + this.settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.TRUST_EUROPE; + this.previousServiceProvider = Program.SERVICE_PROVIDER; + this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider(); + Program.SERVICE_PROVIDER = this.serviceProvider; + + this.provider = new AIStudio.Settings.Provider(1, Guid.NewGuid().ToString(), "Instance 1", LLMProviders.MISTRAL, new("test-model", null)); + } + + [TearDown] + public void RestoreApplicationState() + { + Program.SERVICE_PROVIDER = this.previousServiceProvider; + this.serviceProvider.Dispose(); + if (Directory.Exists(this.dataDirectory)) + Directory.Delete(this.dataDirectory, true); + } + + [Test] + public async Task AStepAsksWithThePromptOfTheLabAndReadsOnlyWebPagesGivenToIt() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.FRAMEWORK); + ChatThread? sent = null; + var service = this.CreateService(thread => sent = thread); + + await service.GetAsync(new BflChatStore(store, location), LAB, location.ChatId, () => BflThreadFactory.Create(location)); + var isSent = await service.SendAsync(location.ChatId, this.Request(location, "Ich spreche auf einem Podium.", new(SYSTEM_PROMPT, []))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isSent, Is.True); + Assert.That(sent!.SystemPrompt, Is.EqualTo(SYSTEM_PROMPT)); + Assert.That(sent.RuntimeSelectedToolIds, Is.EquivalentTo(new[] { ToolSelectionRules.READ_WEB_PAGE_TOOL_ID })); + Assert.That(sent.RuntimeToolsAreAssistantManaged, Is.True); + Assert.That(sent.RuntimeComponent, Is.EqualTo(LAB)); + Assert.That(sent.DataSourceOptions.DisableDataSources, Is.True); + Assert.That(sent.RequiredOutboundDataRestriction, Is.EqualTo(BflChatTools.STEP_REQUIREMENT)); + Assert.That((await store.LoadThreadAsync(location))!.Blocks.Select(block => block.Role), Is.EqualTo(new[] { ChatRole.USER, ChatRole.AI })); + } + } + + [Test] + public async Task TheOnboardingAsksWithoutTools() + { + var store = this.CreateStore(); + var location = BflOnboardingThreadLocation.INSTANCE; + ChatThread? sent = null; + var service = this.CreateService(thread => sent = thread); + + await service.GetAsync(new BflChatStore(store, location), LAB, location.ChatId, () => BflThreadFactory.Create(location)); + await service.SendAsync(location.ChatId, this.Request(location, "Passt das Lab zu meinem Interview?", new(SYSTEM_PROMPT, []))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sent!.RuntimeSelectedToolIds, Is.Empty); + Assert.That(sent.RequiredOutboundDataRestriction, Is.EqualTo(OutboundDataRequirement.NONE)); + Assert.That(File.Exists(Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME, "onboarding", "thread.json")), Is.True); + } + } + + [Test] + public async Task TheImagesOfTheMaterialGoAlongWithTheRequestOnly() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.KNOWLEDGE); + var image = new FileAttachmentImage("Poster.png", Path.Combine(this.dataDirectory, "poster-copy.png"), 1_024); + ChatThread? sent = null; + var service = this.CreateService(thread => sent = thread); + + await service.GetAsync(new BflChatStore(store, location), LAB, location.ChatId, () => BflThreadFactory.Create(location)); + await service.SendAsync(location.ChatId, this.Request(location, BflConversationService.OPENING_REQUEST, new(SYSTEM_PROMPT, [image]), isHidden: true)); + + var stored = await store.LoadThreadAsync(location); + var storedFile = await File.ReadAllTextAsync(Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME, created.ProjectId.ToString("D"), "steps", BflStep.KNOWLEDGE.FolderName, "thread.json")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sent!.Blocks[0].Content!.FileAttachments, Is.EqualTo(new FileAttachment[] { image }), "The image goes along with the first message."); + Assert.That(stored!.Blocks[0].Content!.FileAttachments, Is.Empty, "The stored chat never holds an image."); + Assert.That(stored.Blocks[0].HideFromUser, Is.True); + Assert.That(storedFile, Does.Not.Contain("poster-copy.png")); + } + } + + [Test] + public async Task AChatStoredBeforeTheLabOfferedToolsIsRestrictedBeforeItsRequest() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.STRATEGY); + await store.SaveThreadAsync(location, new ChatThread { ChatId = location.ChatId, Name = location.ChatName, Blocks = [] }); + ChatThread? sent = null; + var service = this.CreateService(thread => sent = thread); + + var conversation = await service.GetAsync(new BflChatStore(store, location), LAB, location.ChatId, () => BflThreadFactory.Create(location)); + var restrictionBefore = conversation.Thread!.RequiredOutboundDataRestriction; + await service.SendAsync(location.ChatId, this.Request(location, "Wer sitzt mit auf dem Podium?", new(SYSTEM_PROMPT, []))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(restrictionBefore, Is.EqualTo(OutboundDataRequirement.NONE)); + Assert.That(sent!.RequiredOutboundDataRestriction, Is.EqualTo(BflChatTools.STEP_REQUIREMENT)); + Assert.That((await store.LoadThreadAsync(location))!.RequiredOutboundDataRestriction, Is.EqualTo(BflChatTools.STEP_REQUIREMENT), "The chat stores the restriction."); + } + } + + private EmbeddedChatRequest Request(BflThreadLocation location, string text, BflRequestMaterial requestMaterial, bool isHidden = false) => new() + { + Provider = this.provider, + Message = new ContentText { Text = text }, + IsHidden = isHidden, + PrepareAsync = thread => Task.FromResult(BflConversationService.Prepare(location, this.provider, thread, requestMaterial)) + }; + + private EmbeddedChatService CreateService(Action sent) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger.Instance, (_, answer, thread, _) => + { + sent(thread); + answer.Text = "Worum geht es Ihnen?"; + return Task.CompletedTask; + }); + + private BeyondFactsLabStore CreateStore() => new(NullLogger.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory }); +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflChatStoreTests.cs b/app/Tests/BeyondFactsLab/BflChatStoreTests.cs new file mode 100644 index 000000000..c02657fbe --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflChatStoreTests.cs @@ -0,0 +1,147 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Chat; +using AIStudio.Chat.Embedded; +using AIStudio.Settings; +using AIStudio.Tools; +using AIStudio.Tools.AssistantSessions; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks that the embedded chats of the BeyondFacts Lab live where the lab keeps its chats, so that every chat stored +/// before stays readable. +/// +[TestFixture] +public sealed class BflChatStoreTests +{ + private string dataDirectory = string.Empty; + + private string RootDirectory => Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME); + + [SetUp] + public void CreateDataDirectory() + { + this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-chat-store-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.dataDirectory); + } + + [TearDown] + public void DeleteDataDirectory() + { + if (Directory.Exists(this.dataDirectory)) + Directory.Delete(this.dataDirectory, true); + } + + [Test] + public async Task AChatStoredBeforeOpensInTheEmbeddedChatWithAllItsMessages() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.KNOWLEDGE); + var thread = BflThreadFactory.Create(location); + AddMessage(thread, ChatRole.USER, BflConversationService.OPENING_REQUEST, isHidden: true); + AddMessage(thread, ChatRole.AI, "Worauf beruht Ihre Expertise?", isHidden: false); + await store.SaveThreadAsync(location, thread); + + var conversation = await CreateService().GetAsync(new BflChatStore(this.CreateStore(), location), AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(conversation.IsAvailable, Is.True); + Assert.That(conversation.Thread!.ChatId, Is.EqualTo(location.ChatId)); + Assert.That(conversation.Thread.Blocks.Select(block => block.HideFromUser), Is.EqualTo(new[] { true, false }), "The hidden request stays hidden."); + Assert.That(conversation.UserMessageCount, Is.Zero); + Assert.That(conversation.Thread.RequiredOutboundDataRestriction, Is.EqualTo(BflChatTools.STEP_REQUIREMENT), "The chat keeps the restriction of its step."); + } + } + + [Test] + public async Task AChatOfAStepIsStoredInTheFolderOfTheStep() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.STRATEGY); + var thread = BflThreadFactory.Create(location); + AddMessage(thread, ChatRole.USER, "Wer sitzt mit auf dem Podium?", isHidden: false); + + await new BflChatStore(store, location).SaveThreadAsync(thread); + + using (Assert.EnterMultipleScope()) + { + Assert.That(File.Exists(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", BflStep.STRATEGY.FolderName, "thread.json")), Is.True); + Assert.That((await store.LoadThreadAsync(location))!.Blocks, Has.Count.EqualTo(1)); + Assert.That(store.ReadAllThreadLocations(), Does.ContainKey(location.ChatId), "The cleanup of the provenance store finds the chat."); + } + } + + [Test] + public async Task TheOnboardingIsStoredInItsOwnFolder() + { + var store = this.CreateStore(); + var location = BflOnboardingThreadLocation.INSTANCE; + var thread = BflThreadFactory.Create(location); + AddMessage(thread, ChatRole.USER, "Passt das Lab zu meinem Interview?", isHidden: false); + + await new BflChatStore(store, location).SaveThreadAsync(thread); + + using (Assert.EnterMultipleScope()) + { + Assert.That(File.Exists(Path.Combine(this.RootDirectory, "onboarding", "thread.json")), Is.True); + Assert.That(store.ReadAllThreadLocations(), Does.ContainKey(location.ChatId)); + } + } + + [Test] + public async Task AChatOfAnotherPlaceIsRefused() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var chatStore = new BflChatStore(store, new BflStepThreadLocation(created.ProjectId, BflStep.FRAMEWORK)); + var otherLocation = new BflStepThreadLocation(created.ProjectId, BflStep.STRATEGY); + + using (Assert.EnterMultipleScope()) + { + await Assert.ThrowsAsync(async () => await chatStore.LoadThreadAsync(otherLocation.ChatId)); + await Assert.ThrowsAsync(async () => await chatStore.SaveThreadAsync(BflThreadFactory.Create(otherLocation))); + await Assert.ThrowsAsync(async () => await chatStore.DeleteThreadAsync(otherLocation.ChatId)); + } + } + + [Test] + public async Task StartingOverDeletesTheFileOfTheChat() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var location = new BflStepThreadLocation(created.ProjectId, BflStep.FRAMEWORK); + var thread = BflThreadFactory.Create(location); + AddMessage(thread, ChatRole.USER, "Hallo", isHidden: false); + await store.SaveThreadAsync(location, thread); + + var service = CreateService(); + var conversation = await service.GetAsync(new BflChatStore(store, location), AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location)); + var reset = await service.ResetAsync(location.ChatId, () => BflThreadFactory.Create(location)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reset, Is.True); + Assert.That(await store.LoadThreadAsync(location), Is.Null); + Assert.That(conversation.Thread!.Blocks, Is.Empty); + Assert.That(conversation.Thread.RequiredOutboundDataRestriction, Is.EqualTo(BflChatTools.STEP_REQUIREMENT), "The new conversation starts restricted as well."); + } + } + + private static void AddMessage(ChatThread thread, ChatRole role, string text, bool isHidden) => thread.Blocks.Add(new ContentBlock + { + Time = DateTimeOffset.Now, + ContentType = ContentType.TEXT, + Role = role, + Content = new ContentText { Text = text }, + HideFromUser = isHidden + }); + + private static EmbeddedChatService CreateService() => new(new SettingsManager(NullLogger.Instance, null!), new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger.Instance, (_, _, _, _) => Task.CompletedTask); + + private BeyondFactsLabStore CreateStore() => new(NullLogger.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory }); +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflMaterialBudgetTests.cs b/app/Tests/BeyondFactsLab/BflMaterialBudgetTests.cs index c8850fbef..c1b70f236 100644 --- a/app/Tests/BeyondFactsLab/BflMaterialBudgetTests.cs +++ b/app/Tests/BeyondFactsLab/BflMaterialBudgetTests.cs @@ -1,4 +1,5 @@ using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Chat.Embedded; using AIStudio.Models; namespace AIStudio.Tests.BeyondFactsLab; @@ -14,7 +15,7 @@ public sealed class BflMaterialBudgetTests { var characters = BflMaterialBudget.CharactersFor(ContextWindow.Of(128_000), 90_000); - Assert.That(characters, Is.EqualTo(((128_000 - BflTokenBudget.RESERVE_TOKENS) * 4 - 90_000) / 2)); + Assert.That(characters, Is.EqualTo(((128_000 - EmbeddedChatTokenBudget.RESERVE_TOKENS) * 4 - 90_000) / 2)); } [Test] @@ -22,7 +23,7 @@ public sealed class BflMaterialBudgetTests { var characters = BflMaterialBudget.CharactersFor(ContextWindow.Of(128_000), 90_000, sentImages: 2); - Assert.That(characters, Is.EqualTo(((128_000 - BflTokenBudget.RESERVE_TOKENS - 2 * BflMaterialBudget.TOKENS_PER_IMAGE) * 4 - 90_000) / 2)); + Assert.That(characters, Is.EqualTo(((128_000 - EmbeddedChatTokenBudget.RESERVE_TOKENS - 2 * BflMaterialBudget.TOKENS_PER_IMAGE) * 4 - 90_000) / 2)); } [Test] diff --git a/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs b/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs index 7ebf0c8a9..4fa810f02 100644 --- a/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs +++ b/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs @@ -198,7 +198,7 @@ public sealed class BflPromptOverridesTests { var settingsManager = new SettingsManager(NullLogger.Instance, null!); var store = new BeyondFactsLabStore(NullLogger.Instance, new BflStorageOptions { DataDirectory = this.directory }); - var service = new BflConversationService(store, settingsManager, null!, null!, null!, NullLogger.Instance); + var service = new BflConversationService(store, null!, settingsManager, null!, null!, NullLogger.Instance); await BflPromptOverrides.CopyBuiltInAsync(store.PromptOverridesDirectory); settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = true; diff --git a/app/Tests/BeyondFactsLab/BflTokenBudgetTests.cs b/app/Tests/BeyondFactsLab/BflTokenBudgetTests.cs deleted file mode 100644 index 3e17e7c4e..000000000 --- a/app/Tests/BeyondFactsLab/BflTokenBudgetTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using AIStudio.Assistants.BeyondFactsLab; -using AIStudio.Chat; -using AIStudio.Models; - -namespace AIStudio.Tests.BeyondFactsLab; - -/// -/// Checks when the BeyondFacts Lab warns about a full context, and when it stops sending. -/// -[TestFixture] -public sealed class BflTokenBudgetTests -{ - private const int WINDOW = 32_000; - - [Test] - public void ACountWhichFailedTellsNothing() - { - Assert.That(BflTokenBudget.Of(ConversationTokens.UNAVAILABLE), Is.EqualTo(BflTokenBudgetState.UNKNOWN)); - } - - [Test] - public void AModelWithoutAStatementAboutItsWindowTellsNothing() - { - var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 500_000, Window = ContextWindow.UNKNOWN }; - - Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(BflTokenBudgetState.UNKNOWN), "The lab cannot know when such a model is full, so it never stops sending."); - } - - [TestCase(0, BflTokenBudgetState.OK)] - [TestCase(25_599, BflTokenBudgetState.OK)] - [TestCase(25_600, BflTokenBudgetState.WARNING)] - [TestCase(WINDOW - BflTokenBudget.RESERVE_TOKENS, BflTokenBudgetState.WARNING)] - [TestCase(WINDOW - BflTokenBudget.RESERVE_TOKENS + 1, BflTokenBudgetState.FULL)] - [TestCase(WINDOW * 2, BflTokenBudgetState.FULL)] - public void TheShareOfTheWindowDecides(int historyTokens, BflTokenBudgetState expected) - { - var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = historyTokens, Window = ContextWindow.Of(WINDOW) }; - - Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(expected)); - } - - [Test] - public void TheMessageBeingWrittenCountsAsWell() - { - var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 20_000, DraftTokens = 6_000, Window = ContextWindow.Of(WINDOW) }; - - Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(BflTokenBudgetState.WARNING)); - } -} \ No newline at end of file diff --git a/app/Tests/Chat/Embedded/EmbeddedChatOpeningTests.cs b/app/Tests/Chat/Embedded/EmbeddedChatOpeningTests.cs index 35fb41418..5a699d46d 100644 --- a/app/Tests/Chat/Embedded/EmbeddedChatOpeningTests.cs +++ b/app/Tests/Chat/Embedded/EmbeddedChatOpeningTests.cs @@ -4,13 +4,15 @@ using AIStudio.Chat.Embedded; namespace AIStudio.Tests.Chat.Embedded; /// -/// Checks when an embedded chat sends the message it opens with. +/// Checks when an embedded chat sends the message it opens with, and when it offers to let the model begin. /// [TestFixture] public sealed class EmbeddedChatOpeningTests { private static readonly ContentText OPENING_MESSAGE = new() { Text = "What is new today?" }; + private const string START_REQUEST = "Open the conversation with your first message."; + [Test] public void AnEmptyChatSendsItsOpeningMessage() { @@ -61,6 +63,57 @@ public sealed class EmbeddedChatOpeningTests Assert.That(EmbeddedChatOpening.SendsNow(OPENING_MESSAGE, conversation, wasSent: false), Is.False); } + [Test] + public void AnEmptyChatOffersToBegin() + { + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, Conversation()), Is.True); + } + + [Test] + public void AChatWithoutStartRequestOffersNothing() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(EmbeddedChatOpening.OffersStart(null, Conversation()), Is.False); + Assert.That(EmbeddedChatOpening.OffersStart(" ", Conversation()), Is.False); + } + } + + [Test] + public void AChatWhoseConversationBeganOffersNothing() + { + var conversation = Conversation(); + conversation.Thread!.Blocks.Add(new ContentBlock { Role = ChatRole.AI, ContentType = ContentType.TEXT, Content = new ContentText { Text = "Hello, what do you prepare?" } }); + + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, conversation), Is.False); + } + + [Test] + public void AChatWithOnlyAHiddenRequestStillOffersToBegin() + { + // E.g., the model failed to answer the request which lets it begin: + var conversation = Conversation(); + conversation.Thread!.Blocks.Add(new ContentBlock { Role = ChatRole.USER, ContentType = ContentType.TEXT, Content = new ContentText { Text = START_REQUEST }, HideFromUser = true }); + + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, conversation), Is.True); + } + + [Test] + public void AChatWhichLoadsRunsOrCannotBeOpenedOffersNothing() + { + var running = Conversation(); + running.IsRunning = true; + var unavailable = Conversation(); + unavailable.Thread = null; + + using (Assert.EnterMultipleScope()) + { + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, null), Is.False); + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, running), Is.False); + Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, unavailable), Is.False); + } + } + private static EmbeddedConversation Conversation() => new() { ChatId = Guid.NewGuid(), diff --git a/app/Tests/Chat/Embedded/EmbeddedChatServiceTests.cs b/app/Tests/Chat/Embedded/EmbeddedChatServiceTests.cs index b31c21def..ddb553b50 100644 --- a/app/Tests/Chat/Embedded/EmbeddedChatServiceTests.cs +++ b/app/Tests/Chat/Embedded/EmbeddedChatServiceTests.cs @@ -130,7 +130,7 @@ public sealed class EmbeddedChatServiceTests var service = this.CreateService(Answer("An answer.")); var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread()); - var sent = await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(false) }); + var sent = await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(EmbeddedChatPreparation.REFUSED) }); using (Assert.EnterMultipleScope()) { @@ -202,7 +202,8 @@ public sealed class EmbeddedChatServiceTests await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread()); var extraBlock = new ContentBlock { Role = ChatRole.USER, ContentType = ContentType.TEXT, Content = new ContentText { Text = "An image description." } }; - await service.SendAsync(chatId, this.Request("A question.") with { ThreadForRequest = thread => thread with { Blocks = [extraBlock, .. thread.Blocks] } }); + var preparation = EmbeddedChatPreparation.READY with { ThreadForRequest = thread => thread with { Blocks = [extraBlock, .. thread.Blocks] } }; + await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(preparation) }); using (Assert.EnterMultipleScope()) { @@ -228,6 +229,83 @@ public sealed class EmbeddedChatServiceTests } } + [Test] + public async Task StartingOverDeletesTheConversationAndKeepsTheIdOfTheChat() + { + var store = new InMemoryEmbeddedChatStore(); + var chatId = Guid.NewGuid(); + var service = this.CreateService(Answer("An answer.")); + + var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread()); + await service.SendAsync(chatId, this.Request("A question.")); + var reset = await service.ResetAsync(chatId, () => new ChatThread { Name = "Started over" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reset, Is.True); + Assert.That(store.IsStored(chatId), Is.False, "The stored conversation is gone."); + Assert.That(conversation.Thread!.Blocks, Is.Empty); + Assert.That(conversation.Thread.ChatId, Is.EqualTo(chatId)); + Assert.That(conversation.Thread.Name, Is.EqualTo("Started over"), "The new conversation starts as a new chat does."); + } + } + + [Test] + public async Task AConversationDoesNotStartOverWhileTheModelAnswers() + { + var store = new InMemoryEmbeddedChatStore(); + var chatId = Guid.NewGuid(); + var answering = new TaskCompletionSource(); + var release = new TaskCompletionSource(); + var service = this.CreateService(async (_, answer, _, _) => + { + answering.SetResult(); + await release.Task; + answer.Text = "An answer."; + }); + + var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread()); + var sending = service.SendAsync(chatId, this.Request("A question.")); + await answering.Task; + + var reset = await service.ResetAsync(chatId, () => new ChatThread()); + release.SetResult(); + await sending; + + using (Assert.EnterMultipleScope()) + { + Assert.That(reset, Is.False); + Assert.That(conversation.Thread!.Blocks, Has.Count.EqualTo(2), "The question and its answer stay."); + Assert.That(store.IsStored(chatId), Is.True); + } + } + + [Test] + public async Task AChatThisVersionCannotOpenBecomesAvailableWhenItStartsOver() + { + var store = new InMemoryEmbeddedChatStore { CannotOpen = true }; + var chatId = Guid.NewGuid(); + var service = this.CreateService(Answer("An answer.")); + + var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread()); + var wasAvailable = conversation.IsAvailable; + var reset = await service.ResetAsync(chatId, () => new ChatThread()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(wasAvailable, Is.False); + Assert.That(reset, Is.True); + Assert.That(conversation.IsAvailable, Is.True); + } + } + + [Test] + public async Task AChatWhichWasNeverLoadedDoesNotStartOver() + { + var service = this.CreateService(Answer("An answer.")); + Assert.That(await service.ResetAsync(Guid.NewGuid(), () => new ChatThread()), Is.False); + } + private EmbeddedChatService CreateService(Func answerAsync) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger.Instance, answerAsync); private EmbeddedChatRequest Request(string text) => new() { Provider = this.provider, Message = new ContentText { Text = text } }; diff --git a/app/Tests/Chat/Embedded/EmbeddedChatTokenBudgetTests.cs b/app/Tests/Chat/Embedded/EmbeddedChatTokenBudgetTests.cs new file mode 100644 index 000000000..d413cd55f --- /dev/null +++ b/app/Tests/Chat/Embedded/EmbeddedChatTokenBudgetTests.cs @@ -0,0 +1,49 @@ +using AIStudio.Chat; +using AIStudio.Chat.Embedded; +using AIStudio.Models; + +namespace AIStudio.Tests.Chat.Embedded; + +/// +/// Checks when an embedded chat which shows the context window warns about a full context, and when it stops sending. +/// +[TestFixture] +public sealed class EmbeddedChatTokenBudgetTests +{ + private const int WINDOW = 32_000; + + [Test] + public void ACountWhichFailedTellsNothing() + { + Assert.That(EmbeddedChatTokenBudget.Of(ConversationTokens.UNAVAILABLE), Is.EqualTo(EmbeddedChatTokenBudgetState.UNKNOWN)); + } + + [Test] + public void AModelWithoutAStatementAboutItsWindowTellsNothing() + { + var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 500_000, Window = ContextWindow.UNKNOWN }; + + Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(EmbeddedChatTokenBudgetState.UNKNOWN), "The chat cannot know when such a model is full, so it never stops sending."); + } + + [TestCase(0, EmbeddedChatTokenBudgetState.OK)] + [TestCase(25_599, EmbeddedChatTokenBudgetState.OK)] + [TestCase(25_600, EmbeddedChatTokenBudgetState.WARNING)] + [TestCase(WINDOW - EmbeddedChatTokenBudget.RESERVE_TOKENS, EmbeddedChatTokenBudgetState.WARNING)] + [TestCase(WINDOW - EmbeddedChatTokenBudget.RESERVE_TOKENS + 1, EmbeddedChatTokenBudgetState.FULL)] + [TestCase(WINDOW * 2, EmbeddedChatTokenBudgetState.FULL)] + public void TheShareOfTheWindowDecides(int historyTokens, EmbeddedChatTokenBudgetState expected) + { + var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = historyTokens, Window = ContextWindow.Of(WINDOW) }; + + Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(expected)); + } + + [Test] + public void TheMessageBeingWrittenCountsAsWell() + { + var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 20_000, DraftTokens = 6_000, Window = ContextWindow.Of(WINDOW) }; + + Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(EmbeddedChatTokenBudgetState.WARNING)); + } +} \ No newline at end of file diff --git a/app/Tests/Chat/Embedded/InMemoryEmbeddedChatStore.cs b/app/Tests/Chat/Embedded/InMemoryEmbeddedChatStore.cs index c2cd630b2..2edec6281 100644 --- a/app/Tests/Chat/Embedded/InMemoryEmbeddedChatStore.cs +++ b/app/Tests/Chat/Embedded/InMemoryEmbeddedChatStore.cs @@ -26,6 +26,12 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore /// The ID of the chat. public IReadOnlyList StoredBlocks(Guid chatId) => this.threads.TryGetValue(chatId, out var thread) ? thread.Blocks : []; + /// + /// Tells whether a chat is stored. + /// + /// The ID of the chat. + public bool IsStored(Guid chatId) => this.threads.ContainsKey(chatId); + /// public Task LoadThreadAsync(Guid chatId, CancellationToken token = default) { @@ -43,4 +49,11 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore this.SaveCount++; return Task.CompletedTask; } + + /// + public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default) + { + this.threads.Remove(chatId); + return Task.CompletedTask; + } } \ No newline at end of file