using System.Collections.Concurrent;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
namespace AIStudio.Assistants.BeyondFactsLab;
///
/// Runs the chats of the steps of the BeyondFacts Lab.
///
///
/// 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 store of the lab.
/// The settings.
/// The registry of running assistant sessions.
/// The message bus, for errors.
/// The logger.
public sealed class BflConversationService(BeyondFactsLabStore store, SettingsManager settingsManager, AssistantSessionService sessionService, MessageBus messageBus, ILogger logger)
{
///
/// The request which lets the model open the conversation of a step. The person does not see it.
///
///
/// German, like the prompts: what the first message does is up to the prompt of the step.
///
public const string OPENING_REQUEST = "Beginne das Gespräch dieses Schritts mit deiner ersten Nachricht, wie es der Abschnitt „Dieser Schritt“ beschreibt.";
///
/// How often a streamed answer renders at most.
///
private static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100);
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService));
private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService));
///
/// The chats loaded so far, by their steps.
///
private readonly ConcurrentDictionary conversations = new();
///
/// Serializes loading a chat, so that two pages never load the same chat twice.
///
private readonly SemaphoreSlim loadLock = new(1, 1);
///
/// Guards whether a chat answers right now, so that two requests never run in the same chat.
///
private readonly Lock runningLock = new();
///
/// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request.
///
///
/// Called on the thread of the request. A page renders through InvokeAsync.
///
public event Func? ConversationChanged;
///
/// Gets the chat of a step, loading it on the first call.
///
/// The step of the preparation.
/// The cancellation token.
/// The chat; it is not available when this version cannot open the stored one.
public async Task GetAsync(BflThreadLocation location, CancellationToken token = default)
{
if (this.conversations.TryGetValue(location, out var conversation))
return conversation;
await this.loadLock.WaitAsync(token);
try
{
if (this.conversations.TryGetValue(location, out conversation))
return conversation;
ChatThread? thread;
try
{
thread = await store.LoadThreadAsync(location, token) ?? BflThreadFactory.Create(location);
}
catch (InvalidDataException exception)
{
// The file stays as it is, see BeyondFactsLabStore.SaveThreadAsync:
logger.LogWarning(exception, "Could not open the chat of the step '{Step}' of the preparation '{ProjectId}' of the BeyondFacts Lab.", location.Step, location.ProjectId);
thread = null;
}
conversation = new BflConversation { Location = location, Thread = thread };
this.conversations[location] = conversation;
return conversation;
}
finally
{
this.loadLock.Release();
}
}
///
/// Lets the model open the conversation of a step, as the prompt of the step describes.
///
/// The step of the preparation.
/// The provider which answers.
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OPENING_REQUEST, isHidden: true);
///
/// Sends a message of the person and lets the model answer it.
///
///
/// 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.
///
/// The step of the preparation.
/// The provider which answers.
/// The message.
/// Whether the person does not see the message, e.g., the request which opens a conversation.
public async Task SendAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, string text, bool isHidden = false)
{
if (string.IsNullOrWhiteSpace(text))
return;
var conversation = await this.GetAsync(location);
if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(thread, provider))
return;
string systemPrompt;
try
{
systemPrompt = await this.ComposeSystemPromptAsync(location);
}
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 step '{Step}'.", location.Step);
await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, TB("The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug.")));
return;
}
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
{
BflThreadFactory.PrepareForRequest(thread, provider, systemPrompt);
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, thread, 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(Icons.Material.Filled.CloudOff, exception.UserMessage));
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(exception, "The BeyondFacts Lab could not store the chat of the step '{Step}'.", location.Step);
await messageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not save this conversation. Please try again.")));
}
finally
{
await this.FinishAsync(conversation, thread, answerBlock, sessionKey, sessionId, status);
}
}
///
/// Stops the answer the model writes in a step.
///
/// The step of the preparation.
public Task CancelAsync(BflThreadLocation location) => sessionService.CancelAsync(SessionKey(location));
///
/// Deletes the conversation of a step, so that it starts over.
///
/// The step of the preparation.
/// False while the model answers in the step.
public async Task 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;
}
///
/// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it.
///
/// The ID of the preparation.
/// The IDs of the chats the preparation could have had.
public async Task> ForgetProjectAsync(Guid projectId)
{
var locations = BflStep.All.Select(step => new BflThreadLocation(projectId, step)).ToList();
foreach (var location in locations)
{
await sessionService.CancelAsync(SessionKey(location));
this.conversations.TryRemove(location, out _);
}
return [.. locations.Select(location => location.ChatId)];
}
///
/// Puts together the system prompt of a step, anew for every request, so that changed details and a changed form
/// of address count right away. The chat also uses it to count what the next request carries.
///
/// The step of the preparation.
/// The system prompt.
/// The app comes without one of the prompts.
/// A prompt has a placeholder without a value.
public async Task ComposeSystemPromptAsync(BflThreadLocation location)
{
var forms = await store.LoadFormsAsync(location.ProjectId);
var parts = await BflPromptLibrary.LoadPartsAsync(location.Step, Tools.PluginSystem.I18N.I.AddressForm);
var language = (await settingsManager.GetActiveLanguagePlugin()).LangName;
return BflPromptComposer.Compose(parts, location.Step, forms, language);
}
///
/// Checks whether a provider may answer in a chat, and tells the person why not.
///
/// The chat.
/// The provider.
/// True when the provider may answer.
private async Task CheckProviderAsync(ChatThread thread, AIStudio.Settings.Provider provider)
{
string? reason = null;
if (provider.UsedLLMProvider is LLMProviders.NONE)
reason = TA("Please select a provider.");
else if (!settingsManager.IsProviderConfident(provider, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT) || !thread.IsLLMProviderAllowed(provider))
reason = TA("The selected provider may not see your preparation. Please select another provider.");
else if (!BflModelRequirements.HasEnoughContext(provider.GetModelProfile().Context))
reason = TA("The model of the selected provider reads too little at once for the lab. Please select another provider.");
if (reason is null)
return true;
await messageBus.SendError(new(Icons.Material.Filled.CloudOff, reason));
return false;
}
///
/// Ends a request: removes an empty answer, stores the chat, and closes the session.
///
/// The chat.
/// The thread of the chat.
/// The block of the answer, or null when the request did not get that far.
/// The key of the session.
/// The ID of the session, or Guid.Empty when none began.
/// How the request ended.
private async Task FinishAsync(BflConversation conversation, ChatThread thread, ContentBlock? answerBlock, AssistantSessionKey sessionKey, Guid sessionId, AssistantSessionStatus status)
{
if (answerBlock is { Content: ContentText answer } && string.IsNullOrWhiteSpace(answer.Text))
thread.Blocks.Remove(answerBlock);
if (answerBlock is not null)
{
try
{
await store.SaveThreadAsync(conversation.Location, thread);
}
catch (FileNotFoundException)
{
// The person deleted the preparation while the model answered; nothing is left to store.
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(exception, "The BeyondFacts Lab could not store the chat of the step '{Step}'.", conversation.Location.Step);
await messageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not save this conversation. Please try again.")));
}
}
lock (this.runningLock)
conversation.IsRunning = false;
if (sessionId != Guid.Empty)
{
await sessionService.CompleteAsync(sessionKey, sessionId, status, string.Empty, thread, new(StringComparer.Ordinal));
// The lab keeps the chat itself; the registry only shows that the lab is at work:
await sessionService.ClearAsync(sessionKey);
}
await this.NotifyAsync(conversation.Location);
}
///
/// Tells every page which listens that a chat changed.
///
/// The step of the chat.
private async Task NotifyAsync(BflThreadLocation location)
{
if (this.ConversationChanged is not { } handlers)
return;
foreach (var handler in handlers.GetInvocationList().Cast>())
{
try
{
await handler(location);
}
catch (Exception exception)
{
// A page which went away meanwhile must not stop the answer:
logger.LogDebug(exception, "A page of the BeyondFacts Lab could not show a change of a chat.");
}
}
}
///
/// Gets the key under which a request of a step registers in AssistantSessionService.
///
/// The step of the preparation.
private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, $"{location.ProjectId:D}/{location.Step.FolderName}");
}