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
+1023
-743
No files matched your search
@@ -1,28 +1,30 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Chat.Embedded;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Assistants.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the chats of the BeyondFacts Lab, such as those of the steps of a preparation.
|
||||
/// Opens the chats of the BeyondFacts Lab, such as those of the steps of a preparation, and puts together what each of
|
||||
/// their requests carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A singleton, so that an answer goes on while the person looks at another page: the page asks for the chat again
|
||||
/// when it comes back and listens to ConversationChanged. Each request registers in AssistantSessionService, which
|
||||
/// shows the work in the navigation. The service does nothing until the page of the lab asks for a chat.
|
||||
/// The chats themselves run in EmbeddedChatService, like the chats of other assistants: it keeps them while the app
|
||||
/// runs, so that an answer goes on while the person looks at another page, and registers each request in
|
||||
/// AssistantSessionService. A chat lives where the lab keeps it, see BflChatStore. What the lab decides for every
|
||||
/// request comes from here, see PrepareAsync: the system prompt with the guide, the details, and the material, the
|
||||
/// images of the material, the tools, and where the chat may send data. The service does nothing until the page of the
|
||||
/// lab asks for a chat.
|
||||
/// </remarks>
|
||||
/// <param name="store">The store of the lab.</param>
|
||||
/// <param name="chatService">The service which runs the embedded chats.</param>
|
||||
/// <param name="settingsManager">The settings.</param>
|
||||
/// <param name="sessionService">The registry of running assistant sessions.</param>
|
||||
/// <param name="messageBus">The message bus, for errors.</param>
|
||||
/// <param name="toolRegistry">The registry of the tools, which tells the chats what they can do on the web.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public sealed class BflConversationService(BeyondFactsLabStore store, SettingsManager settingsManager, AssistantSessionService sessionService, MessageBus messageBus, ToolRegistry toolRegistry, ILogger<BflConversationService> logger)
|
||||
public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedChatService chatService, SettingsManager settingsManager, MessageBus messageBus, ToolRegistry toolRegistry, ILogger<BflConversationService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// The request which lets the model open the conversation of a step. The person does not see it.
|
||||
@@ -38,41 +40,13 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
||||
/// </summary>
|
||||
public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt.";
|
||||
|
||||
/// <summary>
|
||||
/// How often a streamed answer renders at most.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService));
|
||||
|
||||
/// <summary>
|
||||
/// The chats loaded so far, by their places.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<BflThreadLocation, BflConversation> conversations = new();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes loading a chat, so that two pages never load the same chat twice.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim loadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Guards whether a chat answers right now, so that two requests never run in the same chat.
|
||||
/// </summary>
|
||||
private readonly Lock runningLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the folder whose files replace the prompts of the lab, or null while the person does not allow it. The
|
||||
/// setting counts anew for every request, so that switching it on or off shows in the next answer.
|
||||
/// </summary>
|
||||
internal string? PromptOverridesDirectory =>BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called on the thread of the request. A page renders through InvokeAsync.
|
||||
/// </remarks>
|
||||
public event Func<BflThreadLocation, Task>? ConversationChanged;
|
||||
internal string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a chat, loading it on the first call.
|
||||
@@ -80,72 +54,52 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The chat; it is not available when this version cannot open the stored one.</returns>
|
||||
public async Task<BflConversation> GetAsync(BflThreadLocation location, CancellationToken token = default)
|
||||
{
|
||||
if (this.conversations.TryGetValue(location, out var conversation))
|
||||
return conversation;
|
||||
|
||||
await this.loadLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
if (this.conversations.TryGetValue(location, out conversation))
|
||||
return conversation;
|
||||
|
||||
ChatThread? thread;
|
||||
try
|
||||
{
|
||||
thread = await store.LoadThreadAsync(location, token) ?? BflThreadFactory.Create(location);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
// The file stays as it is, see BeyondFactsLabStore.SaveThreadAsync:
|
||||
logger.LogWarning(exception, "Could not open the chat '{Location}' of the BeyondFacts Lab.", location.Key);
|
||||
thread = null;
|
||||
}
|
||||
|
||||
conversation = new BflConversation { Location = location, Thread = thread };
|
||||
this.conversations[location] = conversation;
|
||||
return conversation;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.loadLock.Release();
|
||||
}
|
||||
}
|
||||
public Task<EmbeddedConversation> GetAsync(BflThreadLocation location, CancellationToken token = default) => chatService.GetAsync(this.StoreOf(location), Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location), token);
|
||||
|
||||
/// <summary>
|
||||
/// Lets the model open a conversation, as its prompt describes.
|
||||
/// Gets where a chat of the lab lives.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
/// <param name="provider">The provider which answers.</param>
|
||||
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OpeningRequestFor(location), isHidden: true);
|
||||
/// <returns>The store, which serves this chat only.</returns>
|
||||
public IEmbeddedChatStore StoreOf(BflThreadLocation location) => new BflChatStore(store, location);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request which lets the model open a conversation.
|
||||
/// Gets the request which lets the model open a conversation, see EmbeddedChat.StartRequest. The person does not see
|
||||
/// it.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
/// <returns>The request, which names the section of the prompt that describes the first message.</returns>
|
||||
public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST;
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message of the person and lets the model answer it.
|
||||
/// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it.
|
||||
/// </summary>
|
||||
/// <param name="projectId">The ID of the preparation.</param>
|
||||
/// <returns>The IDs of the chats the preparation could have had.</returns>
|
||||
public async Task<IReadOnlyList<Guid>> ForgetProjectAsync(Guid projectId)
|
||||
{
|
||||
List<Guid> chatIds = [.. BflStep.All.Select(step => new BflStepThreadLocation(projectId, step).ChatId)];
|
||||
foreach (var chatId in chatIds)
|
||||
await chatService.ForgetAsync(chatId);
|
||||
|
||||
return chatIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readies a chat of the lab for a request, see EmbeddedChatRequest.PrepareAsync: checks the provider and puts
|
||||
/// together what the request carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is stored before the model answers, so that a crash loses at most the answer. What goes wrong is
|
||||
/// reported through the message bus; the method itself does not fail.
|
||||
/// What goes wrong is reported through the message bus; the request then does not go out.
|
||||
/// </remarks>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
/// <param name="provider">The provider which answers.</param>
|
||||
/// <param name="text">The message.</param>
|
||||
/// <param name="isHidden">Whether the person does not see the message, e.g., the request which opens a conversation.</param>
|
||||
public async Task SendAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, string text, bool isHidden = false)
|
||||
/// <param name="thread">The chat.</param>
|
||||
/// <returns>What the request carries, or EmbeddedChatPreparation.REFUSED.</returns>
|
||||
public async Task<EmbeddedChatPreparation> PrepareAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return;
|
||||
|
||||
var conversation = await this.GetAsync(location);
|
||||
if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(location, thread, provider))
|
||||
return;
|
||||
if (!await this.CheckProviderAsync(location, thread, provider))
|
||||
return EmbeddedChatPreparation.REFUSED;
|
||||
|
||||
BflRequestMaterial requestMaterial;
|
||||
try
|
||||
@@ -155,139 +109,35 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
||||
catch (BflPromptOverrideException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill.");
|
||||
await messageBus.SendError(new(AppIcons.ERROR, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."),exception.FileName, exception.Placeholder)));
|
||||
return;
|
||||
await messageBus.SendError(new(AppIcons.ERROR, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."), exception.FileName, exception.Placeholder)));
|
||||
return EmbeddedChatPreparation.REFUSED;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
|
||||
{
|
||||
logger.LogError(exception, "The BeyondFacts Lab could not put together the system prompt of the chat '{Location}'.", location.Key);
|
||||
await messageBus.SendError(new(AppIcons.ERROR, TA("The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug.")));
|
||||
return;
|
||||
return EmbeddedChatPreparation.REFUSED;
|
||||
}
|
||||
|
||||
lock (this.runningLock)
|
||||
{
|
||||
if (conversation.IsRunning)
|
||||
return;
|
||||
|
||||
conversation.IsRunning = true;
|
||||
}
|
||||
|
||||
ContentBlock? answerBlock = null;
|
||||
var sessionKey = SessionKey(location);
|
||||
var sessionId = Guid.Empty;
|
||||
var status = AssistantSessionStatus.FAILED;
|
||||
try
|
||||
{
|
||||
// A chat stored before the lab offered tools gets its restriction now, before it may read a page:
|
||||
BflChatTools.Restrict(thread, location);
|
||||
BflThreadFactory.PrepareForRequest(thread, provider, requestMaterial.SystemPrompt, BflChatTools.For(location));
|
||||
var request = new ContentText { Text = text };
|
||||
thread.Blocks.Add(new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.USER,
|
||||
Content = request,
|
||||
HideFromUser = isHidden
|
||||
});
|
||||
|
||||
await store.SaveThreadAsync(location, thread);
|
||||
|
||||
var answer = new ContentText { InitialRemoteWait = true };
|
||||
answerBlock = new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.AI,
|
||||
Content = answer
|
||||
};
|
||||
|
||||
var lastRendering = DateTimeOffset.MinValue;
|
||||
answer.StreamingEvent = async () =>
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
if (now - lastRendering < STREAMING_RENDER_MIN_TIME)
|
||||
return;
|
||||
|
||||
lastRendering = now;
|
||||
await this.NotifyAsync(location);
|
||||
};
|
||||
|
||||
answer.StreamingDone = () => this.NotifyAsync(location);
|
||||
thread.Blocks.Add(answerBlock);
|
||||
|
||||
var cancellation = new CancellationTokenSource();
|
||||
sessionId = (await sessionService.TryBeginAsync(sessionKey, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.Name, cancellation, thread, new(StringComparer.Ordinal))).SessionId;
|
||||
await this.NotifyAsync(location);
|
||||
|
||||
await answer.CreateFromProviderAsync(provider.CreateProvider(), provider.Model, request, WithImages(thread, requestMaterial.Images), cancellation.Token);
|
||||
status = cancellation.IsCancellationRequested ? AssistantSessionStatus.CANCELED : AssistantSessionStatus.COMPLETED;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
status = AssistantSessionStatus.CANCELED;
|
||||
}
|
||||
catch (ProviderRequestException exception)
|
||||
{
|
||||
logger.LogError(exception, "The provider request of the BeyondFacts Lab failed. Status={StatusCode}, Reason='{ReasonPhrase}'.", exception.StatusCode, exception.ReasonPhrase);
|
||||
await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, exception.UserMessage));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", location.Key);
|
||||
await messageBus.SendError(new(AppIcons.SAVE_FAILED, TA("AI Studio could not save this conversation. Please try again.")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this.FinishAsync(conversation, thread, answerBlock, sessionKey, sessionId, status);
|
||||
}
|
||||
return Prepare(location, provider, thread, requestMaterial);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the answer the model writes in a chat.
|
||||
/// Readies a chat of the lab for a request with what the lab put together for it.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
public Task CancelAsync(BflThreadLocation location) => sessionService.CancelAsync(SessionKey(location));
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a conversation, so that it starts over.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
/// <returns>False while the model answers in the chat.</returns>
|
||||
public async Task<bool> ResetAsync(BflThreadLocation location)
|
||||
/// <param name="provider">The provider which answers.</param>
|
||||
/// <param name="thread">The chat, which gets its prompt, its tools, and its restriction.</param>
|
||||
/// <param name="requestMaterial">The system prompt and the images of the material.</param>
|
||||
/// <returns>The preparation, whose request carries the images, see WithImages.</returns>
|
||||
internal static EmbeddedChatPreparation Prepare(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread, BflRequestMaterial requestMaterial)
|
||||
{
|
||||
var conversation = await this.GetAsync(location);
|
||||
lock (this.runningLock)
|
||||
{
|
||||
if (conversation.IsRunning)
|
||||
return false;
|
||||
}
|
||||
// A chat stored before the lab offered tools gets its restriction now, before it may read a page:
|
||||
BflChatTools.Restrict(thread, location);
|
||||
BflThreadFactory.PrepareForRequest(thread, provider, requestMaterial.SystemPrompt, BflChatTools.For(location));
|
||||
|
||||
await store.DeleteThreadAsync(location);
|
||||
conversation.Thread = BflThreadFactory.Create(location);
|
||||
|
||||
// The new conversation keeps the ID of the chat, so what the old one received would otherwise wait for the next start:
|
||||
await ChatProvenance.ForgetChatsAsync([location.ChatId]);
|
||||
await this.NotifyAsync(location);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it.
|
||||
/// </summary>
|
||||
/// <param name="projectId">The ID of the preparation.</param>
|
||||
/// <returns>The IDs of the chats the preparation could have had.</returns>
|
||||
public async Task<IReadOnlyList<Guid>> ForgetProjectAsync(Guid projectId)
|
||||
{
|
||||
var locations = BflStep.All.Select(step => new BflStepThreadLocation(projectId, step)).ToList();
|
||||
foreach (var location in locations)
|
||||
{
|
||||
await sessionService.CancelAsync(SessionKey(location));
|
||||
this.conversations.TryRemove(location, out _);
|
||||
}
|
||||
|
||||
return [.. locations.Select(location => location.ChatId)];
|
||||
var images = requestMaterial.Images;
|
||||
return EmbeddedChatPreparation.READY with { ThreadForRequest = request => WithImages(request, images) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -535,78 +385,4 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
|
||||
await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason));
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends a request: removes an empty answer, stores the chat, and closes the session.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The chat.</param>
|
||||
/// <param name="thread">The thread of the chat.</param>
|
||||
/// <param name="answerBlock">The block of the answer, or null when the request did not get that far.</param>
|
||||
/// <param name="sessionKey">The key of the session.</param>
|
||||
/// <param name="sessionId">The ID of the session, or Guid.Empty when none began.</param>
|
||||
/// <param name="status">How the request ended.</param>
|
||||
private async Task FinishAsync(BflConversation conversation, ChatThread thread, ContentBlock? answerBlock, AssistantSessionKey sessionKey, Guid sessionId, AssistantSessionStatus status)
|
||||
{
|
||||
if (answerBlock is { Content: ContentText answer } && string.IsNullOrWhiteSpace(answer.Text))
|
||||
thread.Blocks.Remove(answerBlock);
|
||||
|
||||
if (answerBlock is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await store.SaveThreadAsync(conversation.Location, thread);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// The person deleted the preparation while the model answered; nothing is left to store.
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", conversation.Location.Key);
|
||||
await messageBus.SendError(new(AppIcons.SAVE_FAILED, TA("AI Studio could not save this conversation. Please try again.")));
|
||||
}
|
||||
}
|
||||
|
||||
lock (this.runningLock)
|
||||
conversation.IsRunning = false;
|
||||
|
||||
if (sessionId != Guid.Empty)
|
||||
{
|
||||
await sessionService.CompleteAsync(sessionKey, sessionId, status, string.Empty, thread, new(StringComparer.Ordinal));
|
||||
|
||||
// The lab keeps the chat itself; the registry only shows that the lab is at work:
|
||||
await sessionService.ClearAsync(sessionKey);
|
||||
}
|
||||
|
||||
await this.NotifyAsync(conversation.Location);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells every page which listens that a chat changed.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
private async Task NotifyAsync(BflThreadLocation location)
|
||||
{
|
||||
if (this.ConversationChanged is not { } handlers)
|
||||
return;
|
||||
|
||||
foreach (var handler in handlers.GetInvocationList().Cast<Func<BflThreadLocation, Task>>())
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler(location);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A page which went away meanwhile must not stop the answer:
|
||||
logger.LogDebug(exception, "A page of the BeyondFacts Lab could not show a change of a chat.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key under which a request of a chat registers in AssistantSessionService.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the chat belongs.</param>
|
||||
private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.Key);
|
||||
}
|
||||
Reference in new issue
Block a user