Moved the chats of the BeyondFacts Lab to the embedded chat of assistants (#1203)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-15 10:32:46 +01:00
1 parent dc97db256e
commit cb54ad297d
27 files changed
+1023 -743

No files matched your search

@@ -1,79 +1,19 @@
@using AIStudio.Chat
@inherits MSGComponentBase
@if (this.ShowHeading)
{
<MudText Typo="Typo.h6" Class="mt-6 mb-2">@T("Conversation")</MudText>
}
@if (this.conversation is null)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-3"/>
}
else if (this.conversation.Thread is not { } thread)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
@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.")
</MudAlert>
<MudButton StartIcon="@AppIcons.RESET" Color="Color.Error" OnClick="@this.ResetAsync">@T("Start over")</MudButton>
}
else
{
@if (!this.conversation.HasVisibleMessages && !this.IsRunning)
{
<MudText Typo="Typo.body2" Class="mb-3">@this.Introduction</MudText>
<div class="mb-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@AppIcons.CHAT" OnClick="@this.StartAsync" Disabled="@(!this.CanSend)">
@T("Start the conversation")
</MudButton>
</div>
}
else
{
@foreach (var block in thread.Blocks)
{
@if (block is { HideFromUser: false, Content: not null })
{
<ContentBlockComponent Role="@block.Role" Type="@block.ContentType" Time="@block.Time" Content="@block.Content" ExportTitle="@T("Export message")" ExportFileName="@this.ExportFileName"/>
}
}
}
@if (this.ProviderIssue is { } issue)
{
<MudText Typo="Typo.body2" Color="Color.Warning" Class="mb-2">@issue</MudText>
}
@* Laid out like the input of the chat page: sending through the icon in the field, and the tokens as its helper text. *@
<MudTextField T="string"
@bind-Value="@this.userInput"
Immediate="true"
Lines="3"
Sizing="InputSizing.Auto"
MaxLines="12"
Variant="Variant.Outlined"
Label="@TA("Your message")"
Adornment="Adornment.End"
AdornmentIcon="@AppIcons.SEND"
AdornmentColor="@(this.CanSendMessage ? Color.Primary : Color.Default)"
AdornmentAriaLabel="@T("Send")"
OnAdornmentClick="@this.SendAsync"
OnKeyUp="@this.InputKeyEventAsync"
UserAttributes="@this.inputAttributes"
HelperText="@this.TokenBudgetText"
Class="@this.InputClass"
Disabled="@(!this.conversation.IsAvailable || this.IsRunning)"/>
@if (this.IsRunning || this.conversation.HasVisibleMessages)
{
<MudStack Row="true" Spacing="1" Class="mt-2">
@if (this.IsRunning)
{
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@AppIcons.STOP" OnClick="@this.CancelAsync">@T("Stop")</MudButton>
}
@if (this.conversation.HasVisibleMessages)
{
<MudButton StartIcon="@AppIcons.RESET" OnClick="@this.ResetAsync" Disabled="@this.IsRunning">@T("Start over")</MudButton>
}
</MudStack>
}
}
<EmbeddedChat Store="@this.ConversationService.StoreOf(this.Location)"
Component="@Components.BEYOND_FACTS_LAB_ASSISTANT"
ChatId="@this.Location.ChatId"
CreateThread="@this.CreateThread"
Provider="@this.Provider"
PrepareAsync="@this.prepareAsync"
ProviderIssue="@this.ProviderIssue"
Heading="@(this.ShowHeading ? T("Conversation") : null)"
StartRequest="@BflConversationService.OpeningRequestFor(this.Location)"
Introduction="@this.Introduction"
ExportFileName="@this.ExportFileName"
AllowsReset="true"
ResetQuestion="@this.ResetQuestion"
AllowsAttachments="false"
ShowsContextWindow="true"
SystemPromptForCountAsync="@this.systemPromptForCountAsync"
ContextVersion="@this.materialsVersion"/>
@@ -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".
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<BflChat> Logger { get; init; } = null!;
[Inject]
private ConversationTokenCounter TokenCounter { get; init; } = null!;
/// <summary>
/// Readies a request of the chat shown, with the provider chosen; made anew whenever either changes.
/// </summary>
private Func<ChatThread, Task<EmbeddedChatPreparation>> prepareAsync = _ => Task.FromResult(EmbeddedChatPreparation.REFUSED);
/// <summary>
/// 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.
/// </summary>
private readonly CancellationTokenSource countCancellation = new();
private Func<Task<string?>> systemPromptForCountAsync = () => Task.FromResult<string?>(null);
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private CancellationToken countToken;
/// <summary>
/// What the next request carries, as far as it was counted.
/// </summary>
private ConversationTokens tokens = ConversationTokens.UNAVAILABLE;
/// <summary>
/// The provider the tokens were counted for, to count again when the parent chooses another one.
/// </summary>
private string countedProviderId = string.Empty;
/// <summary>
/// The chat shown, or null while it loads.
/// </summary>
private BflConversation? conversation;
/// <summary>
/// Where the chat shown belongs, to notice when the parent shows another one.
/// </summary>
private BflThreadLocation? shownLocation;
/// <summary>
/// The message the person writes.
/// </summary>
private string userInput = string.Empty;
/// <summary>
/// The attributes of the message field, such as the spell checking the person chose.
/// </summary>
private readonly Dictionary<string, object?> 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;
/// <summary>
/// Gets what the chat says before its conversation begins.
@@ -143,10 +97,7 @@ public partial class BflChat : MSGComponentBase
/// <inheritdoc />
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);
}
/// <inheritdoc />
protected override void DisposeResources()
{
this.ConversationService.ConversationChanged -= this.OnConversationChangedAsync;
this.MaterialService.MaterialsChanged -= this.OnMaterialsChangedAsync;
this.countCancellation.Cancel();
this.countCancellation.Dispose();
base.DisposeResources();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="location">Where the chat which changed belongs.</param>
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);
/// <summary>
/// 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.
/// </summary>
/// <param name="projectId">The ID of the preparation whose material changed.</param>
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();
});
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private async Task RecountAsync()
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers.</param>
/// <returns>The system prompt, or null when it cannot be put together now.</returns>
private async Task<string?> 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;
}
}
/// <summary>
/// Gets how full the context of the model is, or null when nothing could be counted.
/// </summary>
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
};
}
}
/// <summary>
/// Gets the classes of the message field; like on the chat page, they colour its helper text with the tokens.
/// </summary>
private string InputClass => this.Budget switch
{
BflTokenBudgetState.FULL => "mt-2 token-budget-exceeded",
BflTokenBudgetState.WARNING => "mt-2 token-budget-nearly-full",
_ => "mt-2"
};
/// <summary>
/// Lets the model open the conversation.
/// </summary>
private async Task StartAsync()
{
if (this.CanSend)
await this.ConversationService.StartAsync(this.Location, this.Provider);
}
/// <summary>
/// Sends the message of the person.
/// </summary>
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);
}
/// <summary>
/// Sends the message when the person releases the shortcut they chose for the chat.
/// </summary>
/// <param name="keyEvent">The key released.</param>
private async Task InputKeyEventAsync(KeyboardEventArgs keyEvent)
{
if (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior.IsSendShortcut(keyEvent))
await this.SendAsync();
}
/// <summary>
/// Stops the answer the model writes.
/// </summary>
private Task CancelAsync() => this.ConversationService.CancelAsync(this.Location);
/// <summary>
/// Deletes the conversation after asking, so that it starts over.
/// </summary>
private async Task ResetAsync()
{
var parameters = new DialogParameters<ConfirmDialog>
{
{ dialog => dialog.Message, this.ResetQuestion }
};
var reference = await this.DialogService.ShowAsync<ConfirmDialog>(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;
}
}
}
@@ -0,0 +1,38 @@
using AIStudio.Chat;
using AIStudio.Chat.Embedded;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="store">The store of the lab.</param>
/// <param name="location">The place of the chat.</param>
public sealed class BflChatStore(BeyondFactsLabStore store, BflThreadLocation location) : IEmbeddedChatStore
{
/// <inheritdoc />
public Task<ChatThread?> LoadThreadAsync(Guid chatId, CancellationToken token = default) => store.LoadThreadAsync(this.LocationOf(chatId), token);
/// <inheritdoc />
public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default) => store.SaveThreadAsync(this.LocationOf(thread.ChatId), thread, token);
/// <inheritdoc />
public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default) => store.DeleteThreadAsync(this.LocationOf(chatId), token);
/// <summary>
/// Gets the place of a chat, which must be the place of this store.
/// </summary>
/// <param name="chatId">The ID of the chat.</param>
/// <returns>The place of the store.</returns>
/// <exception cref="ArgumentException">The chat belongs to another place.</exception>
private BflThreadLocation LocationOf(Guid chatId) => chatId == location.ChatId
? location
: throw new ArgumentException("The chat belongs to another place of the lab.", nameof(chatId));
}
@@ -1,38 +0,0 @@
using AIStudio.Chat;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// A chat of the BeyondFacts Lab, as BflConversationService keeps it while the app runs.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class BflConversation
{
/// <summary>
/// Gets where the chat belongs, e.g., to a step of a preparation.
/// </summary>
public required BflThreadLocation Location { get; init; }
/// <summary>
/// Gets or sets the chat, or null when this version cannot open the stored one.
/// </summary>
public ChatThread? Thread { get; set; }
/// <summary>
/// Gets or sets whether the model answers right now.
/// </summary>
public bool IsRunning { get; set; }
/// <summary>
/// Gets whether the chat can be shown and continued.
/// </summary>
public bool IsAvailable => this.Thread is not null;
/// <summary>
/// Gets whether the chat has a message the person sees.
/// </summary>
public bool HasVisibleMessages => this.Thread?.Blocks.Any(block => !block.HideFromUser) ?? false;
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="store">The store of the lab.</param>
/// <param name="chatService">The service which runs the embedded chats.</param>
/// <param name="settingsManager">The settings.</param>
/// <param name="sessionService">The registry of running assistant sessions.</param>
/// <param name="messageBus">The message bus, for errors.</param>
/// <param name="toolRegistry">The registry of the tools, which tells the chats what they can do on the web.</param>
/// <param name="logger">The logger.</param>
public sealed class BflConversationService(BeyondFactsLabStore store, SettingsManager settingsManager, AssistantSessionService sessionService, MessageBus messageBus, ToolRegistry toolRegistry, ILogger<BflConversationService> logger)
public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedChatService chatService, SettingsManager settingsManager, MessageBus messageBus, ToolRegistry toolRegistry, ILogger<BflConversationService> logger)
{
/// <summary>
/// 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
/// </summary>
public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt.";
/// <summary>
/// How often a streamed answer renders at most.
/// </summary>
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));
/// <summary>
/// The chats loaded so far, by their places.
/// </summary>
private readonly ConcurrentDictionary<BflThreadLocation, BflConversation> conversations = new();
/// <summary>
/// Serializes loading a chat, so that two pages never load the same chat twice.
/// </summary>
private readonly SemaphoreSlim loadLock = new(1, 1);
/// <summary>
/// Guards whether a chat answers right now, so that two requests never run in the same chat.
/// </summary>
private readonly Lock runningLock = new();
/// <summary>
/// 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.
/// </summary>
internal string? PromptOverridesDirectory =>BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
/// <summary>
/// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request.
/// </summary>
/// <remarks>
/// Called on the thread of the request. A page renders through InvokeAsync.
/// </remarks>
public event Func<BflThreadLocation, Task>? ConversationChanged;
internal string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
/// <summary>
/// Gets a chat, loading it on the first call.
@@ -80,72 +54,52 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// <param name="location">Where the chat belongs.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The chat; it is not available when this version cannot open the stored one.</returns>
public async Task<BflConversation> 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<EmbeddedConversation> GetAsync(BflThreadLocation location, CancellationToken token = default) => chatService.GetAsync(this.StoreOf(location), Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location), token);
/// <summary>
/// Lets the model open a conversation, as its prompt describes.
/// Gets where a chat of the lab lives.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers.</param>
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OpeningRequestFor(location), isHidden: true);
/// <returns>The store, which serves this chat only.</returns>
public IEmbeddedChatStore StoreOf(BflThreadLocation location) => new BflChatStore(store, location);
/// <summary>
/// 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.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <returns>The request, which names the section of the prompt that describes the first message.</returns>
public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST;
/// <summary>
/// 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.
/// </summary>
/// <param name="projectId">The ID of the preparation.</param>
/// <returns>The IDs of the chats the preparation could have had.</returns>
public async Task<IReadOnlyList<Guid>> ForgetProjectAsync(Guid projectId)
{
List<Guid> chatIds = [.. BflStep.All.Select(step => new BflStepThreadLocation(projectId, step).ChatId)];
foreach (var chatId in chatIds)
await chatService.ForgetAsync(chatId);
return chatIds;
}
/// <summary>
/// Readies a chat of the lab for a request, see EmbeddedChatRequest.PrepareAsync: checks the provider and puts
/// together what the request carries.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers.</param>
/// <param name="text">The message.</param>
/// <param name="isHidden">Whether the person does not see the message, e.g., the request which opens a conversation.</param>
public async Task SendAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, string text, bool isHidden = false)
/// <param name="thread">The chat.</param>
/// <returns>What the request carries, or EmbeddedChatPreparation.REFUSED.</returns>
public async Task<EmbeddedChatPreparation> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
public Task CancelAsync(BflThreadLocation location) => sessionService.CancelAsync(SessionKey(location));
/// <summary>
/// Deletes a conversation, so that it starts over.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <returns>False while the model answers in the chat.</returns>
public async Task<bool> ResetAsync(BflThreadLocation location)
/// <param name="provider">The provider which answers.</param>
/// <param name="thread">The chat, which gets its prompt, its tools, and its restriction.</param>
/// <param name="requestMaterial">The system prompt and the images of the material.</param>
/// <returns>The preparation, whose request carries the images, see WithImages.</returns>
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;
}
/// <summary>
/// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it.
/// </summary>
/// <param name="projectId">The ID of the preparation.</param>
/// <returns>The IDs of the chats the preparation could have had.</returns>
public async Task<IReadOnlyList<Guid>> 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) };
}
/// <summary>
@@ -535,78 +385,4 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason));
return false;
}
/// <summary>
/// Ends a request: removes an empty answer, stores the chat, and closes the session.
/// </summary>
/// <param name="conversation">The chat.</param>
/// <param name="thread">The thread of the chat.</param>
/// <param name="answerBlock">The block of the answer, or null when the request did not get that far.</param>
/// <param name="sessionKey">The key of the session.</param>
/// <param name="sessionId">The ID of the session, or Guid.Empty when none began.</param>
/// <param name="status">How the request ended.</param>
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);
}
/// <summary>
/// Tells every page which listens that a chat changed.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
private async Task NotifyAsync(BflThreadLocation location)
{
if (this.ConversationChanged is not { } handlers)
return;
foreach (var handler in handlers.GetInvocationList().Cast<Func<BflThreadLocation, Task>>())
{
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.");
}
}
}
/// <summary>
/// Gets the key under which a request of a chat registers in AssistantSessionService.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.Key);
}
@@ -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);
}
@@ -1,40 +0,0 @@
using AIStudio.Chat;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// Decides how much room a chat of the BeyondFacts Lab has left in the context of its model.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class BflTokenBudget
{
/// <summary>
/// From which share of the context on the lab warns.
/// </summary>
public const double WARNING_SHARE = 0.8;
/// <summary>
/// How many tokens the lab keeps free for the next message and its answer.
/// </summary>
public const int RESERVE_TOKENS = 4_096;
/// <summary>
/// Decides how much room a chat has left.
/// </summary>
/// <param name="tokens">What the conversation costs, with the system prompt, and the context of the model.</param>
/// <returns>The state of the budget.</returns>
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;
}
}
@@ -1,27 +0,0 @@
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// How much room a chat of the BeyondFacts Lab has left in the context of its model.
/// </summary>
public enum BflTokenBudgetState
{
/// <summary>
/// Nobody knows: the count failed, or the model has no statement about its context.
/// </summary>
UNKNOWN,
/// <summary>
/// Room enough.
/// </summary>
OK,
/// <summary>
/// Most of the context is used; the person should think about starting over.
/// </summary>
WARNING,
/// <summary>
/// The next message and its answer would not fit anymore, so the lab sends no more.
/// </summary>
FULL,
}
@@ -15,4 +15,7 @@ public sealed class ModelComparisonSeriesChatStore(ModelComparisonStore store, G
/// <inheritdoc />
public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default) => store.SaveSeriesChatAsync(seriesId, thread, token);
/// <inheritdoc />
public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default) => store.DeleteSeriesChatAsync(seriesId, chatId, token);
}
@@ -104,7 +104,7 @@ public partial class ModelComparisonSeriesView : MSGComponentBase
/// <summary>
/// Readies a request of the chat the user works in right now, with the provider of that chat.
/// </summary>
private Func<ChatThread, Task<bool>> prepareChat = _ => Task.FromResult(true);
private Func<ChatThread, Task<EmbeddedChatPreparation>> prepareChat = _ => Task.FromResult(EmbeddedChatPreparation.READY);
/// <summary>
/// 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
/// </summary>
/// <param name="thread">The chat.</param>
/// <param name="provider">The provider which answers in the chat.</param>
/// <returns>Always true: whether the provider may answer, the embedded chat checks itself.</returns>
private Task<bool> PrepareAsync(ChatThread thread, AIStudio.Settings.Provider provider)
/// <returns>Always ready: whether the provider may answer, the embedded chat checks itself.</returns>
private Task<EmbeddedChatPreparation> 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);
}
/// <summary>