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

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

No files matched your search

@@ -1,7 +1,8 @@
namespace AIStudio.Chat.Embedded;
/// <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>
public static class EmbeddedChatOpening
{
@@ -19,4 +20,17 @@ public static class EmbeddedChatOpening
/// <returns>True when the chat sends the message now.</returns>
public static bool SendsNow(ContentText? openingMessage, EmbeddedConversation? conversation, bool wasSent) =>
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>
/// 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>
public Func<ChatThread, Task<bool>> PrepareAsync { get; init; } = _ => Task.FromResult(true);
/// <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;
public Func<ChatThread, Task<EmbeddedChatPreparation>> PrepareAsync { get; init; } = _ => Task.FromResult(EmbeddedChatPreparation.READY);
}
@@ -134,7 +134,8 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista
var status = AssistantSessionStatus.FAILED;
try
{
if (!await request.PrepareAsync(thread) || !await this.CheckProviderAsync(conversation, thread, request.Provider))
var preparation = await request.PrepareAsync(thread);
if (!preparation.IsReady || !await this.CheckProviderAsync(conversation, thread, request.Provider))
return false;
thread.RuntimeComponent = conversation.Component;
@@ -177,7 +178,7 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista
sessionId = (await sessionService.TryBeginAsync(sessionKey, conversation.Component.Name, cancellation, thread, new(StringComparer.Ordinal))).SessionId;
await this.NotifyAsync(chatId);
await this.answerAsync(request, answer, request.ThreadForRequest(thread), cancellation.Token);
await this.answerAsync(request, answer, preparation.ThreadForRequest(thread), cancellation.Token);
status = cancellation.IsCancellationRequested ? AssistantSessionStatus.CANCELED : AssistantSessionStatus.COMPLETED;
}
catch (OperationCanceledException)
@@ -214,6 +215,40 @@ public sealed class EmbeddedChatService(SettingsManager settingsManager, Assista
? sessionService.CancelAsync(SessionKey(conversation))
: Task.CompletedTask;
/// <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>
/// Stops the answer of a chat and forgets the chat, e.g., because the user deleted what it belonged to.
/// </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;
}
}
@@ -0,0 +1,27 @@
namespace AIStudio.Chat.Embedded;
/// <summary>
/// How much room an embedded chat has left in the context of its model.
/// </summary>
public enum EmbeddedChatTokenBudgetState
{
/// <summary>
/// Nobody knows: the count failed, or the model has no statement about its context.
/// </summary>
UNKNOWN,
/// <summary>
/// Room enough.
/// </summary>
OK,
/// <summary>
/// Most of the context is used; the user should think about starting over.
/// </summary>
WARNING,
/// <summary>
/// The next message and its answer would not fit anymore, so the chat sends no more.
/// </summary>
FULL,
}
@@ -25,4 +25,11 @@ public interface IEmbeddedChatStore
/// <param name="token">The cancellation token.</param>
/// <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);
/// <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);
}