mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-15 12:08:27 +00:00
Moved the chats of the BeyondFacts Lab to the embedded chat of assistants (#1203)
This commit is contained in:
1 parent
dc97db256e
commit
cb54ad297d
27 files changed
+1017
-737
No files matched your search
@@ -1,79 +1,19 @@
|
|||||||
@using AIStudio.Chat
|
|
||||||
@inherits MSGComponentBase
|
@inherits MSGComponentBase
|
||||||
|
|
||||||
@if (this.ShowHeading)
|
<EmbeddedChat Store="@this.ConversationService.StoreOf(this.Location)"
|
||||||
{
|
Component="@Components.BEYOND_FACTS_LAB_ASSISTANT"
|
||||||
<MudText Typo="Typo.h6" Class="mt-6 mb-2">@T("Conversation")</MudText>
|
ChatId="@this.Location.ChatId"
|
||||||
}
|
CreateThread="@this.CreateThread"
|
||||||
@if (this.conversation is null)
|
Provider="@this.Provider"
|
||||||
{
|
PrepareAsync="@this.prepareAsync"
|
||||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-3"/>
|
ProviderIssue="@this.ProviderIssue"
|
||||||
}
|
Heading="@(this.ShowHeading ? T("Conversation") : null)"
|
||||||
else if (this.conversation.Thread is not { } thread)
|
StartRequest="@BflConversationService.OpeningRequestFor(this.Location)"
|
||||||
{
|
Introduction="@this.Introduction"
|
||||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
ExportFileName="@this.ExportFileName"
|
||||||
@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.")
|
AllowsReset="true"
|
||||||
</MudAlert>
|
ResetQuestion="@this.ResetQuestion"
|
||||||
<MudButton StartIcon="@AppIcons.RESET" Color="Color.Error" OnClick="@this.ResetAsync">@T("Start over")</MudButton>
|
AllowsAttachments="false"
|
||||||
}
|
ShowsContextWindow="true"
|
||||||
else
|
SystemPromptForCountAsync="@this.systemPromptForCountAsync"
|
||||||
{
|
ContextVersion="@this.materialsVersion"/>
|
||||||
@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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
using AIStudio.Chat;
|
using AIStudio.Chat;
|
||||||
|
using AIStudio.Chat.Embedded;
|
||||||
using AIStudio.Components;
|
using AIStudio.Components;
|
||||||
using AIStudio.Dialogs;
|
|
||||||
using AIStudio.Settings.DataModel;
|
|
||||||
using AIStudio.Tools.Services;
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
|
||||||
|
|
||||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
|
||||||
|
|
||||||
namespace AIStudio.Assistants.BeyondFactsLab;
|
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".
|
/// A chat of the BeyondFacts Lab, such as the one of a step below its form "Your details".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The chat itself lives in BflConversationService, so an answer goes on while the person looks elsewhere; this
|
/// The embedded chat of assistants with what the lab decides for each of its places: the opening request, the
|
||||||
/// component shows it and renders whenever the service says it changed. It offers no "send to": the lab neither
|
/// introduction, the question before starting over, and, for every request, the system prompt with the guide, the
|
||||||
/// receives content from other assistants nor hands its answers on.
|
/// 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>
|
/// </remarks>
|
||||||
public partial class BflChat : MSGComponentBase
|
public partial class BflChat : MSGComponentBase
|
||||||
{
|
{
|
||||||
@@ -51,67 +48,24 @@ public partial class BflChat : MSGComponentBase
|
|||||||
[Inject]
|
[Inject]
|
||||||
private BflMaterialService MaterialService { get; init; } = null!;
|
private BflMaterialService MaterialService { get; init; } = null!;
|
||||||
|
|
||||||
[Inject]
|
|
||||||
private IDialogService DialogService { get; init; } = null!;
|
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<BflChat> Logger { get; init; } = null!;
|
private ILogger<BflChat> Logger { get; init; } = null!;
|
||||||
|
|
||||||
[Inject]
|
/// <summary>
|
||||||
private ConversationTokenCounter TokenCounter { get; init; } = null!;
|
/// 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>
|
/// <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>
|
/// </summary>
|
||||||
private readonly CancellationTokenSource countCancellation = new();
|
private Func<Task<string?>> systemPromptForCountAsync = () => Task.FromResult<string?>(null);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
private int materialsVersion;
|
||||||
/// 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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets what the chat says before its conversation begins.
|
/// Gets what the chat says before its conversation begins.
|
||||||
@@ -143,10 +97,7 @@ public partial class BflChat : MSGComponentBase
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
this.countToken = this.countCancellation.Token;
|
|
||||||
this.ConversationService.ConversationChanged += this.OnConversationChangedAsync;
|
|
||||||
this.MaterialService.MaterialsChanged += this.OnMaterialsChangedAsync;
|
this.MaterialService.MaterialsChanged += this.OnMaterialsChangedAsync;
|
||||||
this.SettingsManager.InjectSpellchecking(this.inputAttributes);
|
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,48 +105,28 @@ public partial class BflChat : MSGComponentBase
|
|||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
await base.OnParametersSetAsync();
|
await base.OnParametersSetAsync();
|
||||||
if (this.shownLocation != this.Location)
|
|
||||||
{
|
// Bound to the chat and the provider shown now, so that a request never takes those of the next render:
|
||||||
this.shownLocation = this.Location;
|
var location = this.Location;
|
||||||
this.conversation = null;
|
var provider = this.Provider;
|
||||||
this.userInput = string.Empty;
|
this.prepareAsync = thread => this.ConversationService.PrepareAsync(location, provider, thread);
|
||||||
this.conversation = await this.ConversationService.GetAsync(this.Location);
|
this.systemPromptForCountAsync = () => this.SystemPromptForCountAsync(location, provider);
|
||||||
await this.RecountAsync();
|
|
||||||
}
|
|
||||||
else if (this.countedProviderId != this.Provider.Id)
|
|
||||||
await this.RecountAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void DisposeResources()
|
protected override void DisposeResources()
|
||||||
{
|
{
|
||||||
this.ConversationService.ConversationChanged -= this.OnConversationChangedAsync;
|
|
||||||
this.MaterialService.MaterialsChanged -= this.OnMaterialsChangedAsync;
|
this.MaterialService.MaterialsChanged -= this.OnMaterialsChangedAsync;
|
||||||
this.countCancellation.Cancel();
|
|
||||||
this.countCancellation.Dispose();
|
|
||||||
base.DisposeResources();
|
base.DisposeResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="location">Where the chat which changed belongs.</param>
|
private ChatThread CreateThread() => BflThreadFactory.Create(this.Location);
|
||||||
private Task OnConversationChangedAsync(BflThreadLocation location)
|
|
||||||
{
|
|
||||||
if (location != this.Location)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
return this.InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
if (!this.IsRunning)
|
|
||||||
await this.RecountAsync();
|
|
||||||
|
|
||||||
this.StateHasChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="projectId">The ID of the preparation whose material changed.</param>
|
/// <param name="projectId">The ID of the preparation whose material changed.</param>
|
||||||
private Task OnMaterialsChangedAsync(Guid projectId)
|
private Task OnMaterialsChangedAsync(Guid projectId)
|
||||||
@@ -203,145 +134,30 @@ public partial class BflChat : MSGComponentBase
|
|||||||
if (this.Location is not BflStepThreadLocation stepLocation || stepLocation.ProjectId != projectId)
|
if (this.Location is not BflStepThreadLocation stepLocation || stepLocation.ProjectId != projectId)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return this.InvokeAsync(async () =>
|
return this.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
if (!this.IsRunning)
|
this.materialsVersion++;
|
||||||
await this.RecountAsync();
|
|
||||||
|
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
/// Only while no answer runs: the request appends to the conversation which the count reads. The message being
|
/// <param name="provider">The provider which answers.</param>
|
||||||
/// written is left out; the reserve of the budget keeps room for it.
|
/// <returns>The system prompt, or null when it cannot be put together now.</returns>
|
||||||
/// </remarks>
|
private async Task<string?> SystemPromptForCountAsync(BflThreadLocation location, AIStudio.Settings.Provider provider)
|
||||||
private async Task RecountAsync()
|
|
||||||
{
|
{
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
var systemPrompt = await this.ConversationService.ComposeSystemPromptAsync(this.Location, this.Provider);
|
return await this.ConversationService.ComposeSystemPromptAsync(location, 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.
|
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
|
||||||
{
|
{
|
||||||
// Sending reports the same problem; the count only stays unknown:
|
// 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.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;
|
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.")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
using AIStudio.Chat.Embedded;
|
||||||
using AIStudio.Provider;
|
using AIStudio.Provider;
|
||||||
using AIStudio.Settings;
|
using AIStudio.Settings;
|
||||||
using AIStudio.Tools.AssistantSessions;
|
|
||||||
using AIStudio.Tools.ToolCallingSystem;
|
using AIStudio.Tools.ToolCallingSystem;
|
||||||
|
|
||||||
namespace AIStudio.Assistants.BeyondFactsLab;
|
namespace AIStudio.Assistants.BeyondFactsLab;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A singleton, so that an answer goes on while the person looks at another page: the page asks for the chat again
|
/// The chats themselves run in EmbeddedChatService, like the chats of other assistants: it keeps them while the app
|
||||||
/// when it comes back and listens to ConversationChanged. Each request registers in AssistantSessionService, which
|
/// runs, so that an answer goes on while the person looks at another page, and registers each request in
|
||||||
/// shows the work in the navigation. The service does nothing until the page of the lab asks for a chat.
|
/// 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>
|
/// </remarks>
|
||||||
/// <param name="store">The store of the lab.</param>
|
/// <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="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="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="toolRegistry">The registry of the tools, which tells the chats what they can do on the web.</param>
|
||||||
/// <param name="logger">The logger.</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>
|
/// <summary>
|
||||||
/// The request which lets the model open the conversation of a step. The person does not see it.
|
/// The request which lets the model open the conversation of a step. The person does not see it.
|
||||||
@@ -38,114 +40,66 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt.";
|
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));
|
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>
|
/// <summary>
|
||||||
/// Gets the folder whose files replace the prompts of the lab, or null while the person does not allow it. The
|
/// 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.
|
/// setting counts anew for every request, so that switching it on or off shows in the next answer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
|
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;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a chat, loading it on the first call.
|
/// Gets a chat, loading it on the first call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="location">Where the chat belongs.</param>
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
/// <param name="token">The cancellation token.</param>
|
/// <param name="token">The cancellation token.</param>
|
||||||
/// <returns>The chat; it is not available when this version cannot open the stored one.</returns>
|
/// <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)
|
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);
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lets the model open a conversation, as its prompt describes.
|
/// Gets where a chat of the lab lives.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="location">Where the chat belongs.</param>
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
/// <param name="provider">The provider which answers.</param>
|
/// <returns>The store, which serves this chat only.</returns>
|
||||||
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OpeningRequestFor(location), isHidden: true);
|
public IEmbeddedChatStore StoreOf(BflThreadLocation location) => new BflChatStore(store, location);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="location">Where the chat belongs.</param>
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
/// <returns>The request, which names the section of the prompt that describes the first message.</returns>
|
/// <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;
|
public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The message is stored before the model answers, so that a crash loses at most the answer. What goes wrong is
|
/// What goes wrong is reported through the message bus; the request then does not go out.
|
||||||
/// reported through the message bus; the method itself does not fail.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="location">Where the chat belongs.</param>
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
/// <param name="provider">The provider which answers.</param>
|
/// <param name="provider">The provider which answers.</param>
|
||||||
/// <param name="text">The message.</param>
|
/// <param name="thread">The chat.</param>
|
||||||
/// <param name="isHidden">Whether the person does not see the message, e.g., the request which opens a conversation.</param>
|
/// <returns>What the request carries, or EmbeddedChatPreparation.REFUSED.</returns>
|
||||||
public async Task SendAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, string text, bool isHidden = false)
|
public async Task<EmbeddedChatPreparation> PrepareAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (!await this.CheckProviderAsync(location, thread, provider))
|
||||||
return;
|
return EmbeddedChatPreparation.REFUSED;
|
||||||
|
|
||||||
var conversation = await this.GetAsync(location);
|
|
||||||
if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(location, thread, provider))
|
|
||||||
return;
|
|
||||||
|
|
||||||
BflRequestMaterial requestMaterial;
|
BflRequestMaterial requestMaterial;
|
||||||
try
|
try
|
||||||
@@ -156,138 +110,34 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
|||||||
{
|
{
|
||||||
logger.LogWarning(exception, "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill.");
|
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)));
|
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;
|
return EmbeddedChatPreparation.REFUSED;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
|
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);
|
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.")));
|
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)
|
return Prepare(location, provider, thread, requestMaterial);
|
||||||
{
|
|
||||||
if (conversation.IsRunning)
|
|
||||||
return;
|
|
||||||
|
|
||||||
conversation.IsRunning = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ContentBlock? answerBlock = null;
|
/// <summary>
|
||||||
var sessionKey = SessionKey(location);
|
/// Readies a chat of the lab for a request with what the lab put together for it.
|
||||||
var sessionId = Guid.Empty;
|
/// </summary>
|
||||||
var status = AssistantSessionStatus.FAILED;
|
/// <param name="location">Where the chat belongs.</param>
|
||||||
try
|
/// <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)
|
||||||
{
|
{
|
||||||
// A chat stored before the lab offered tools gets its restriction now, before it may read a page:
|
// A chat stored before the lab offered tools gets its restriction now, before it may read a page:
|
||||||
BflChatTools.Restrict(thread, location);
|
BflChatTools.Restrict(thread, location);
|
||||||
BflThreadFactory.PrepareForRequest(thread, provider, requestMaterial.SystemPrompt, BflChatTools.For(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 images = requestMaterial.Images;
|
||||||
|
return EmbeddedChatPreparation.READY with { ThreadForRequest = request => WithImages(request, images) };
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the answer the model writes in a chat.
|
|
||||||
/// </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)
|
|
||||||
{
|
|
||||||
var conversation = await this.GetAsync(location);
|
|
||||||
lock (this.runningLock)
|
|
||||||
{
|
|
||||||
if (conversation.IsRunning)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -535,78 +385,4 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
|||||||
await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason));
|
await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason));
|
||||||
return false;
|
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;
|
using AIStudio.Models;
|
||||||
|
|
||||||
namespace AIStudio.Assistants.BeyondFactsLab;
|
namespace AIStudio.Assistants.BeyondFactsLab;
|
||||||
@@ -83,7 +84,7 @@ public static class BflMaterialBudget
|
|||||||
if (!contextWindow.IsKnown)
|
if (!contextWindow.IsKnown)
|
||||||
return CHARACTERS_WITHOUT_A_KNOWN_CONTEXT_WINDOW;
|
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;
|
var freeCharacters = freeTokens * CHARACTERS_PER_TOKEN - promptCharacters;
|
||||||
return (int)Math.Clamp((long)(freeCharacters * SHARE_OF_THE_FREE_CONTEXT), 0, MAX_CHARACTERS);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,4 +15,7 @@ public sealed class ModelComparisonSeriesChatStore(ModelComparisonStore store, G
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default) => store.SaveSeriesChatAsync(seriesId, thread, token);
|
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);
|
||||||
}
|
}
|
||||||
+4
-4
@@ -104,7 +104,7 @@ public partial class ModelComparisonSeriesView : MSGComponentBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Readies a request of the chat the user works in right now, with the provider of that chat.
|
/// Readies a request of the chat the user works in right now, with the provider of that chat.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Func<ChatThread, Task<bool>> prepareChat = _ => Task.FromResult(true);
|
private Func<ChatThread, Task<EmbeddedChatPreparation>> prepareChat = _ => Task.FromResult(EmbeddedChatPreparation.READY);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The copy of the first message of the first chat, with which the second chat starts, or null in the first chat.
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="thread">The chat.</param>
|
/// <param name="thread">The chat.</param>
|
||||||
/// <param name="provider">The provider which answers in 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>
|
/// <returns>Always ready: whether the provider may answer, the embedded chat checks itself.</returns>
|
||||||
private Task<bool> PrepareAsync(ChatThread thread, AIStudio.Settings.Provider provider)
|
private Task<EmbeddedChatPreparation> PrepareAsync(ChatThread thread, AIStudio.Settings.Provider provider)
|
||||||
{
|
{
|
||||||
var toolIds = this.ToolRegistry.FilterToolIdsForProvider(provider, this.Series.ToolIds, thread.RequiredOutboundDataRestriction.Restriction);
|
var toolIds = this.ToolRegistry.FilterToolIdsForProvider(provider, this.Series.ToolIds, thread.RequiredOutboundDataRestriction.Restriction);
|
||||||
ModelComparisonSeriesThreads.PrepareForRequest(thread, this.Series, provider, toolIds);
|
ModelComparisonSeriesThreads.PrepareForRequest(thread, this.Series, provider, toolIds);
|
||||||
return Task.FromResult(true);
|
return Task.FromResult(EmbeddedChatPreparation.READY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
namespace AIStudio.Chat.Embedded;
|
namespace AIStudio.Chat.Embedded;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EmbeddedChatOpening
|
public static class EmbeddedChatOpening
|
||||||
{
|
{
|
||||||
@@ -19,4 +20,17 @@ public static class EmbeddedChatOpening
|
|||||||
/// <returns>True when the chat sends the message now.</returns>
|
/// <returns>True when the chat sends the message now.</returns>
|
||||||
public static bool SendsNow(ContentText? openingMessage, EmbeddedConversation? conversation, bool wasSent) =>
|
public static bool SendsNow(ContentText? openingMessage, EmbeddedConversation? conversation, bool wasSent) =>
|
||||||
openingMessage is not null && !wasSent && conversation is { IsAvailable: true, IsRunning: false, UserMessageCount: 0 };
|
openingMessage is not null && !wasSent && conversation is { IsAvailable: true, IsRunning: false, UserMessageCount: 0 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells whether an embedded chat offers to let the model open its conversation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only before anything the user sees: once the model or the user wrote, the conversation has begun. The user may
|
||||||
|
/// always write first instead.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="startRequest">The request which lets the model begin, or null without one.</param>
|
||||||
|
/// <param name="conversation">The chat shown, or null while it loads.</param>
|
||||||
|
/// <returns>True when the chat offers to begin.</returns>
|
||||||
|
public static bool OffersStart(string? startRequest, EmbeddedConversation? conversation) =>
|
||||||
|
!string.IsNullOrWhiteSpace(startRequest) && conversation is { IsAvailable: true, IsRunning: false, HasVisibleMessages: false };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace AIStudio.Chat.Embedded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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:
|
||||||
|
/// <c>EmbeddedChatPreparation.READY with { ThreadForRequest = … }</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record EmbeddedChatPreparation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The request goes out with the chat as it is.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly EmbeddedChatPreparation READY = new() { IsReady = true };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The request does not go out; whoever readied it told the user why.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly EmbeddedChatPreparation REFUSED = new() { IsReady = false };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether the request goes out.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsReady { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the chat which the request sends, e.g., with images which the stored chat never holds. The chat itself by
|
||||||
|
/// default.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public Func<ChatThread, ChatThread> ThreadForRequest { get; init; } = thread => thread;
|
||||||
|
}
|
||||||
@@ -27,13 +27,8 @@ public sealed record EmbeddedChatRequest
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Readies the chat for the request before the message goes in: its system prompt, profile, tools, and data
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<ChatThread, Task<bool>> PrepareAsync { get; init; } = _ => Task.FromResult(true);
|
public Func<ChatThread, Task<EmbeddedChatPreparation>> PrepareAsync { get; init; } = _ => Task.FromResult(EmbeddedChatPreparation.READY);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the chat which the request sends, e.g., with images which the stored chat never holds. The chat itself
|
|
||||||
/// by default.
|
|
||||||
/// </summary>
|
|
||||||
public Func<ChatThread, ChatThread> ThreadForRequest { get; init; } = thread => thread;
|
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,8 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista
|
|||||||
var status = AssistantSessionStatus.FAILED;
|
var status = AssistantSessionStatus.FAILED;
|
||||||
try
|
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;
|
return false;
|
||||||
|
|
||||||
thread.RuntimeComponent = conversation.Component;
|
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;
|
sessionId = (await sessionService.TryBeginAsync(sessionKey, conversation.Component.Name, cancellation, thread, new(StringComparer.Ordinal))).SessionId;
|
||||||
await this.NotifyAsync(chatId);
|
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;
|
status = cancellation.IsCancellationRequested ? AssistantSessionStatus.CANCELED : AssistantSessionStatus.COMPLETED;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -214,6 +215,40 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista
|
|||||||
? sessionService.CancelAsync(SessionKey(conversation))
|
? sessionService.CancelAsync(SessionKey(conversation))
|
||||||
: Task.CompletedTask;
|
: Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the conversation of a chat, so that it starts over, e.g., because the user asked for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="chatId">The ID of a chat which GetAsync loaded before.</param>
|
||||||
|
/// <param name="createThread">Creates the thread of the new conversation; it gets the ID of the chat.</param>
|
||||||
|
/// <returns>False while the model answers in the chat, or when it was not loaded.</returns>
|
||||||
|
/// <exception cref="IOException">The stored chat could not be deleted; the conversation stays as it is.</exception>
|
||||||
|
/// <exception cref="UnauthorizedAccessException">The stored chat could not be deleted; the conversation stays as it is.</exception>
|
||||||
|
public async Task<bool> ResetAsync(Guid chatId, Func<ChatThread> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops the answer of a chat and forgets the chat, e.g., because the user deleted what it belonged to.
|
/// Stops the answer of a chat and forgets the chat, e.g., because the user deleted what it belonged to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace AIStudio.Chat.Embedded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides how much room an embedded chat has left in the context of its model, see EmbeddedChat.ShowsContextWindow.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static class EmbeddedChatTokenBudget
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// From which share of the context on the chat warns.
|
||||||
|
/// </summary>
|
||||||
|
public const double WARNING_SHARE = 0.8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many tokens the chat 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
namespace AIStudio.Assistants.BeyondFactsLab;
|
namespace AIStudio.Chat.Embedded;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum BflTokenBudgetState
|
public enum EmbeddedChatTokenBudgetState
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Nobody knows: the count failed, or the model has no statement about its context.
|
/// Nobody knows: the count failed, or the model has no statement about its context.
|
||||||
@@ -16,12 +16,12 @@ public enum BflTokenBudgetState
|
|||||||
OK,
|
OK,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
WARNING,
|
WARNING,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
FULL,
|
FULL,
|
||||||
}
|
}
|
||||||
@@ -25,4 +25,11 @@ public interface IEmbeddedChatStore
|
|||||||
/// <param name="token">The cancellation token.</param>
|
/// <param name="token">The cancellation token.</param>
|
||||||
/// <exception cref="FileNotFoundException">What the chat belonged to was deleted meanwhile; nothing is left to store.</exception>
|
/// <exception cref="FileNotFoundException">What the chat belonged to was deleted meanwhile; nothing is left to store.</exception>
|
||||||
public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default);
|
public Task SaveThreadAsync(ChatThread thread, CancellationToken token = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a chat, so that its conversation starts over, also one which this version cannot open.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chatId">The ID of the chat.</param>
|
||||||
|
/// <param name="token">The cancellation token.</param>
|
||||||
|
public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
@using AIStudio.Chat
|
@using AIStudio.Chat
|
||||||
|
@using AIStudio.Chat.Embedded
|
||||||
@inherits MSGComponentBase
|
@inherits MSGComponentBase
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(this.Heading))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mt-6 mb-2">@this.Heading</MudText>
|
||||||
|
}
|
||||||
@if (this.conversation is null)
|
@if (this.conversation is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-3"/>
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-3"/>
|
||||||
}
|
}
|
||||||
else if (this.conversation.Thread is not { } thread)
|
else if (this.conversation.Thread is not { } thread)
|
||||||
|
{
|
||||||
|
@if (this.CanReset)
|
||||||
|
{
|
||||||
|
<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
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
||||||
@T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.")
|
@T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.")
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@if (!this.conversation.HasVisibleMessages && !this.IsRunning && !string.IsNullOrWhiteSpace(this.Introduction))
|
@if (!this.conversation.HasVisibleMessages && !this.IsRunning && !string.IsNullOrWhiteSpace(this.Introduction))
|
||||||
@@ -18,6 +33,15 @@ else
|
|||||||
<MudText Typo="Typo.body2" Class="mb-3">@this.Introduction</MudText>
|
<MudText Typo="Typo.body2" Class="mb-3">@this.Introduction</MudText>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!this.IsReadOnly && EmbeddedChatOpening.OffersStart(this.StartRequest, this.conversation))
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
@foreach (var block in thread.Blocks)
|
@foreach (var block in thread.Blocks)
|
||||||
{
|
{
|
||||||
@if (block is { HideFromUser: false, Content: not null })
|
@if (block is { HideFromUser: false, Content: not null })
|
||||||
@@ -50,7 +74,7 @@ else
|
|||||||
OnKeyUp="@this.InputKeyEventAsync"
|
OnKeyUp="@this.InputKeyEventAsync"
|
||||||
UserAttributes="@this.inputAttributes"
|
UserAttributes="@this.inputAttributes"
|
||||||
HelperText="@this.TokenText"
|
HelperText="@this.TokenText"
|
||||||
Class="mt-2"
|
Class="@this.InputClass"
|
||||||
Disabled="@(!this.CanSend)"/>
|
Disabled="@(!this.CanSend)"/>
|
||||||
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mt-2">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mt-2">
|
||||||
@@ -62,6 +86,10 @@ else
|
|||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@AppIcons.STOP" OnClick="@this.CancelAsync">@T("Stop")</MudButton>
|
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@AppIcons.STOP" OnClick="@this.CancelAsync">@T("Stop")</MudButton>
|
||||||
}
|
}
|
||||||
|
@if (this.CanReset && this.conversation.HasVisibleMessages)
|
||||||
|
{
|
||||||
|
<MudButton StartIcon="@AppIcons.RESET" OnClick="@this.ResetAsync" Disabled="@this.IsRunning">@T("Start over")</MudButton>
|
||||||
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using AIStudio.Chat;
|
using AIStudio.Chat;
|
||||||
using AIStudio.Chat.Embedded;
|
using AIStudio.Chat.Embedded;
|
||||||
|
using AIStudio.Dialogs;
|
||||||
using AIStudio.Provider;
|
using AIStudio.Provider;
|
||||||
using AIStudio.Settings.DataModel;
|
using AIStudio.Settings.DataModel;
|
||||||
using AIStudio.Tools.Rust;
|
using AIStudio.Tools.Rust;
|
||||||
@@ -8,6 +9,8 @@ using AIStudio.Tools.Services;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
|
||||||
|
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||||
|
|
||||||
namespace AIStudio.Components;
|
namespace AIStudio.Components;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -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
|
/// 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
|
/// 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
|
/// 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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public partial class EmbeddedChat : MSGComponentBase
|
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.
|
/// Gets or sets how a request is readied before the message goes in, see EmbeddedChatRequest.PrepareAsync.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<ChatThread, Task<bool>> PrepareAsync { get; set; } = _ => Task.FromResult(true);
|
public Func<ChatThread, Task<EmbeddedChatPreparation>> PrepareAsync { get; set; } = _ => Task.FromResult(EmbeddedChatPreparation.READY);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether messages to the user leave out which provider and which model answer.
|
/// 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]
|
[Parameter]
|
||||||
public bool IsReadOnly { get; set; }
|
public bool IsReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool AllowsReset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? ResetQuestion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool ShowsContextWindow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It must not fail: whatever goes wrong, sending reports anyway.
|
||||||
|
/// </remarks>
|
||||||
|
[Parameter]
|
||||||
|
public Func<Task<string?>>? SystemPromptForCountAsync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public int ContextVersion { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the user may attach files to a message.
|
/// Gets or sets whether the user may attach files to a message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -94,6 +139,20 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public ContentText? OpeningMessage { get; set; }
|
public ContentText? OpeningMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? StartRequest { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a heading above the chat, or nothing where the place of the chat names it already.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? Heading { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets why the provider cannot answer, or null when it can.
|
/// Gets or sets why the provider cannot answer, or null when it can.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -118,14 +177,25 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
[Inject]
|
[Inject]
|
||||||
private ConversationTokenCounter TokenCounter { get; init; } = null!;
|
private ConversationTokenCounter TokenCounter { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IDialogService DialogService { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<EmbeddedChat> Logger { get; init; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ends a count when the component goes away.
|
/// Ends a count when the component goes away.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly CancellationTokenSource countCancellation = new();
|
private readonly CancellationTokenSource countCancellation = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The token of countCancellation, taken while the source still lives, see BflChat.
|
/// The token of countCancellation, taken while the source still lives.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
private CancellationToken countToken;
|
private CancellationToken countToken;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -138,6 +208,12 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private string countedProviderId = string.Empty;
|
private string countedProviderId = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The version of what a request carries beside the conversation which the tokens were counted for, see
|
||||||
|
/// ContextVersion.
|
||||||
|
/// </summary>
|
||||||
|
private int countedContextVersion;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The chat shown, or null while it loads.
|
/// The chat shown, or null while it loads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -170,10 +246,14 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
|
|
||||||
private bool IsRunning => this.conversation?.IsRunning ?? false;
|
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 CanSendMessage => this.CanSend && !string.IsNullOrWhiteSpace(this.userInput);
|
||||||
|
|
||||||
|
private bool CanReset => this.AllowsReset && !this.IsReadOnly;
|
||||||
|
|
||||||
#region Overrides of MSGComponentBase
|
#region Overrides of MSGComponentBase
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
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);
|
this.conversation = await this.ChatService.GetAsync(this.Store, this.Component, this.ChatId, this.CreateThread);
|
||||||
await this.RecountAsync();
|
await this.RecountAsync();
|
||||||
}
|
}
|
||||||
else if (this.countedProviderId != this.Provider.Id)
|
else if (this.countedProviderId != this.Provider.Id || this.countedContextVersion != this.ContextVersion)
|
||||||
await this.RecountAsync();
|
await this.RecountAsync();
|
||||||
|
|
||||||
// Not awaited: the answer arrives through ConversationChanged, and the parameters of the next render must not wait for it.
|
// 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.
|
/// Counts what the conversation carries so far, while no answer runs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The system prompt counts as the chat stores it: what PrepareAsync adds for a request is not known before. The
|
/// The system prompt counts as SystemPromptForCountAsync gives it, otherwise as the chat stores it: what
|
||||||
/// count is therefore an estimate, and the message being written is left out.
|
/// PrepareAsync adds for a request is not known before then. The count is therefore an estimate, and the message
|
||||||
|
/// being written is left out.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RecountAsync()
|
private async Task RecountAsync()
|
||||||
{
|
{
|
||||||
this.countedProviderId = this.Provider.Id;
|
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)
|
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;
|
this.tokens = ConversationTokens.UNAVAILABLE;
|
||||||
@@ -253,7 +335,14 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
|
|
||||||
try
|
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);
|
this.tokens = await this.TokenCounter.CountAsync(this.Provider, parts, thread.ReportedHistoryFor(this.Provider.Model), this.countToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -263,11 +352,58 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string? TokenText => this.tokens.IsKnown
|
private string? TokenText
|
||||||
? string.Format(T("approx. {0} tokens"), TokenAmount.Format(this.tokens.Tokens, Tools.PluginSystem.I18N.I.Culture))
|
{
|
||||||
: null;
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets what the user can do once the conversation fills the context of the model.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the warning once the conversation fills most of the context of the model.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
EmbeddedChatTokenBudgetState.FULL => "mt-2 token-budget-exceeded",
|
||||||
|
EmbeddedChatTokenBudgetState.WARNING => "mt-2 token-budget-nearly-full",
|
||||||
|
_ => "mt-2"
|
||||||
|
};
|
||||||
|
|
||||||
private Task AttachmentsChanged(HashSet<FileAttachment> changedAttachments)
|
private Task AttachmentsChanged(HashSet<FileAttachment> changedAttachments)
|
||||||
{
|
{
|
||||||
@@ -289,6 +425,15 @@ public partial class EmbeddedChat : MSGComponentBase
|
|||||||
await this.ChatService.SendAsync(this.ChatId, this.RequestOf(message));
|
await this.ChatService.SendAsync(this.ChatId, this.RequestOf(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets the model open the conversation, see StartRequest.
|
||||||
|
/// </summary>
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends the opening message. When it cannot be sent, e.g., because the provider may not answer, the message waits
|
/// 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.
|
/// 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.
|
/// Stops the answer the model writes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Task CancelAsync() => this.ChatService.CancelAsync(this.ChatId);
|
private Task CancelAsync() => this.ChatService.CancelAsync(this.ChatId);
|
||||||
|
|
||||||
|
/// <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 ?? TA("Delete this conversation and start it over?") }
|
||||||
|
};
|
||||||
|
|
||||||
|
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.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.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks what a request of a chat of the BeyondFacts Lab carries when it runs in the embedded chat, without asking any
|
||||||
|
/// provider.
|
||||||
|
/// </summary>
|
||||||
|
[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<SettingsManager>.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<ChatThread> sent) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger<EmbeddedChatService>.Instance, (_, answer, thread, _) =>
|
||||||
|
{
|
||||||
|
sent(thread);
|
||||||
|
answer.Text = "Worum geht es Ihnen?";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
private BeyondFactsLabStore CreateStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks that the embedded chats of the BeyondFacts Lab live where the lab keeps its chats, so that every chat stored
|
||||||
|
/// before stays readable.
|
||||||
|
/// </summary>
|
||||||
|
[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<ArgumentException>(async () => await chatStore.LoadThreadAsync(otherLocation.ChatId));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(async () => await chatStore.SaveThreadAsync(BflThreadFactory.Create(otherLocation)));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(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<SettingsManager>.Instance, null!), new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger<EmbeddedChatService>.Instance, (_, _, _, _) => Task.CompletedTask);
|
||||||
|
|
||||||
|
private BeyondFactsLabStore CreateStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AIStudio.Assistants.BeyondFactsLab;
|
using AIStudio.Assistants.BeyondFactsLab;
|
||||||
|
using AIStudio.Chat.Embedded;
|
||||||
using AIStudio.Models;
|
using AIStudio.Models;
|
||||||
|
|
||||||
namespace AIStudio.Tests.BeyondFactsLab;
|
namespace AIStudio.Tests.BeyondFactsLab;
|
||||||
@@ -14,7 +15,7 @@ public sealed class BflMaterialBudgetTests
|
|||||||
{
|
{
|
||||||
var characters = BflMaterialBudget.CharactersFor(ContextWindow.Of(128_000), 90_000);
|
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]
|
[Test]
|
||||||
@@ -22,7 +23,7 @@ public sealed class BflMaterialBudgetTests
|
|||||||
{
|
{
|
||||||
var characters = BflMaterialBudget.CharactersFor(ContextWindow.Of(128_000), 90_000, sentImages: 2);
|
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]
|
[Test]
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ public sealed class BflPromptOverridesTests
|
|||||||
{
|
{
|
||||||
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||||
var store = new BeyondFactsLabStore(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.directory });
|
var store = new BeyondFactsLabStore(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.directory });
|
||||||
var service = new BflConversationService(store, settingsManager, null!, null!, null!, NullLogger<BflConversationService>.Instance);
|
var service = new BflConversationService(store, null!, settingsManager, null!, null!, NullLogger<BflConversationService>.Instance);
|
||||||
await BflPromptOverrides.CopyBuiltInAsync(store.PromptOverridesDirectory);
|
await BflPromptOverrides.CopyBuiltInAsync(store.PromptOverridesDirectory);
|
||||||
|
|
||||||
settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = true;
|
settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = true;
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using AIStudio.Assistants.BeyondFactsLab;
|
|
||||||
using AIStudio.Chat;
|
|
||||||
using AIStudio.Models;
|
|
||||||
|
|
||||||
namespace AIStudio.Tests.BeyondFactsLab;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks when the BeyondFacts Lab warns about a full context, and when it stops sending.
|
|
||||||
/// </summary>
|
|
||||||
[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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,15 @@ using AIStudio.Chat.Embedded;
|
|||||||
namespace AIStudio.Tests.Chat.Embedded;
|
namespace AIStudio.Tests.Chat.Embedded;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public sealed class EmbeddedChatOpeningTests
|
public sealed class EmbeddedChatOpeningTests
|
||||||
{
|
{
|
||||||
private static readonly ContentText OPENING_MESSAGE = new() { Text = "What is new today?" };
|
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]
|
[Test]
|
||||||
public void AnEmptyChatSendsItsOpeningMessage()
|
public void AnEmptyChatSendsItsOpeningMessage()
|
||||||
{
|
{
|
||||||
@@ -61,6 +63,57 @@ public sealed class EmbeddedChatOpeningTests
|
|||||||
Assert.That(EmbeddedChatOpening.SendsNow(OPENING_MESSAGE, conversation, wasSent: false), Is.False);
|
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()
|
private static EmbeddedConversation Conversation() => new()
|
||||||
{
|
{
|
||||||
ChatId = Guid.NewGuid(),
|
ChatId = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public sealed class EmbeddedChatServiceTests
|
|||||||
var service = this.CreateService(Answer("An answer."));
|
var service = this.CreateService(Answer("An answer."));
|
||||||
|
|
||||||
var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
|
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())
|
using (Assert.EnterMultipleScope())
|
||||||
{
|
{
|
||||||
@@ -202,7 +202,8 @@ public sealed class EmbeddedChatServiceTests
|
|||||||
|
|
||||||
await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
|
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." } };
|
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())
|
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<EmbeddedChatRequest, ContentText, ChatThread, CancellationToken, Task> answerAsync) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger<EmbeddedChatService>.Instance, answerAsync);
|
private EmbeddedChatService CreateService(Func<EmbeddedChatRequest, ContentText, ChatThread, CancellationToken, Task> answerAsync) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger<EmbeddedChatService>.Instance, answerAsync);
|
||||||
|
|
||||||
private EmbeddedChatRequest Request(string text) => new() { Provider = this.provider, Message = new ContentText { Text = text } };
|
private EmbeddedChatRequest Request(string text) => new() { Provider = this.provider, Message = new ContentText { Text = text } };
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using AIStudio.Chat;
|
||||||
|
using AIStudio.Chat.Embedded;
|
||||||
|
using AIStudio.Models;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Chat.Embedded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks when an embedded chat which shows the context window warns about a full context, and when it stops sending.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore
|
|||||||
/// <param name="chatId">The ID of the chat.</param>
|
/// <param name="chatId">The ID of the chat.</param>
|
||||||
public IReadOnlyList<ContentBlock> StoredBlocks(Guid chatId) => this.threads.TryGetValue(chatId, out var thread) ? thread.Blocks : [];
|
public IReadOnlyList<ContentBlock> StoredBlocks(Guid chatId) => this.threads.TryGetValue(chatId, out var thread) ? thread.Blocks : [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells whether a chat is stored.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chatId">The ID of the chat.</param>
|
||||||
|
public bool IsStored(Guid chatId) => this.threads.ContainsKey(chatId);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ChatThread?> LoadThreadAsync(Guid chatId, CancellationToken token = default)
|
public Task<ChatThread?> LoadThreadAsync(Guid chatId, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
@@ -43,4 +49,11 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore
|
|||||||
this.SaveCount++;
|
this.SaveCount++;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
this.threads.Remove(chatId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user