using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Security;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools.AssistantLaunch;
///
/// Starts an assistant or a chat with content sent to it, such as the result of an assistant or a message of a chat.
///
///
/// Every place which sends content goes through here, so that all of them offer the same destinations and
/// hand the content over the same way. The service lives once per window, because it navigates there.
///
/// The settings, which decide the assistants the user sees.
/// The sessions of the assistants, which decide whether a destination is busy.
/// The navigation of the window the user sends from.
/// The filter for what an AI wrote, before it reaches another model.
public sealed class AssistantLaunchService(SettingsManager settingsManager, AssistantSessionService assistantSessionService, NavigationManager navigationManager, PromptInjectionGuardService promptInjectionGuardService)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantLaunchService).Namespace, nameof(AssistantLaunchService));
///
/// The assistants and the chat which a component may send content to, in the order of the menu.
///
/// The component which sends.
/// The destinations the user can choose from.
public IReadOnlyList GetDestinationsFor(Components sender) => SelectDestinations(sender, this.IsVisible);
///
/// Prepares a text for sending, together with the restrictions it takes along.
///
///
/// What an AI wrote goes through the prompt injection filter first: an answer may repeat what the AI
/// read, such as a web page or a mail, and the instructions hidden there with it. Once sent, the text
/// reaches the next model as a message of the user, which nobody filters. The user hears about what
/// was removed right away, since each sending is an action of its own. The web addresses of the
/// filtered text count as written by a model, so none of them counts as given by the user later
/// on.
/// What the user wrote is sent as it is.
///
/// The text to send.
/// Whether an AI wrote the text, rather than the user.
/// The restrictions of where the text comes from.
/// The name of the chat or of the assistant the text comes from, for the report of the filter.
/// The text to send, with its restrictions.
public async Task CreateTextPayloadAsync(string text, bool writtenByModel, ChatRestrictions sourceRestrictions, string sourceName)
{
if (!writtenByModel)
return new(text, sourceRestrictions);
var filteredText = await promptInjectionGuardService.SanitizeAsync(text, PromptInjectionSource.ModelAnswer(sourceName));
return new(filteredText, sourceRestrictions.WithWebAddressesWrittenByModelIn(filteredText));
}
///
/// Prepares the text of a message of a chat for sending, together with what the chat demands.
///
///
/// A message of the AI counts as written by a model, a message of the user does not, see the overload
/// for a text. The restrictions are those of the chat as it is now: a message of the user written before
/// the chat read a mailbox demands what the chat demands since, which is the safe side.
///
/// The chat the message belongs to.
/// The content of the message.
/// The text to send, with its restrictions, or null when the content is no message of the chat with a text to send.
public async Task CreateTextPayloadAsync(ChatThread sourceChat, IContent content)
{
var block = sourceChat.Blocks.FirstOrDefault(candidate => ReferenceEquals(candidate.Content, content));
if (block is not { Role: ChatRole.AI or ChatRole.USER, Content: ContentText { Text: var text } } || string.IsNullOrWhiteSpace(text))
return null;
var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name;
return await this.CreateTextPayloadAsync(text, writtenByModel: block.Role is ChatRole.AI, sourceChat.CaptureRestrictions(), sourceName);
}
///
/// Opens the destination with the text sent to it.
///
///
/// An assistant which is running already keeps its run: the user gets a warning and sees the running
/// session, and the text is not handed over. Otherwise, what an assistant left from an earlier run
/// gives way to the text, so that the text is not overwritten when the assistant restores that run.
/// The destination takes the text once it opens, see TakeTextPayload.
///
/// The assistant, or the chat for the field of the next message.
/// The text to send.
public async Task LaunchAsync(Components destination, TextLaunchPayload payload)
{
var route = destination.GetData().Route;
if (destination.HasSingleSessionSlot() && assistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && snapshot.Key.Component == destination))
{
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Apps, TB("This assistant is already running. AI Studio opens the running session instead.")));
navigationManager.NavigateTo(route);
return;
}
// Only components with a single session slot may be cleared as a group. The visual briefing
// assistant keys its sessions per briefing, so clearing by component would discard the
// status of every stored briefing instead of the one we are about to open.
if (destination.HasSingleSessionSlot())
await assistantSessionService.ClearInactiveSessionsForComponentAsync(destination);
MessageBus.INSTANCE.DeferMessage(null, TextEventFor(destination), payload);
navigationManager.NavigateTo(route);
}
///
/// Takes the text which was sent to a component, if any.
///
///
/// The text waits for its destination until the destination opens. Only the last one sent counts, and
/// taking it removes it, so that it arrives once.
///
/// The component which opens.
/// The text sent, or null when nothing was sent to the component.
public static TextLaunchPayload? TakeTextPayload(Components destination) => destination.AllowSendTo()
? MessageBus.INSTANCE.TakeDeferredMessages(TextEventFor(destination)).LastOrDefault()
: null;
///
/// The event a text for the given destination travels with.
///
///
/// A text sent to the chat goes into the field for the next message rather than starting a chat of its
/// own, which is why the chat takes it with an event of its own.
///
/// The assistant or the chat.
/// The event.
internal static Event TextEventFor(Components destination) => destination is Components.CHAT
? Event.SEND_TO_CHAT_INPUT
: destination.GetData().Event;
///
/// The destinations a component may send content to, see GetDestinationsFor.
///
///
/// An assistant never offers itself: the content would arrive in the very assistant it came from,
/// and since its page is open already, it would not even show up there. A chat still offers a new
/// chat, because that is another chat. Shorter names come first, as everywhere in the menu.
///
/// The component which sends.
/// Whether the user sees the given assistant at all.
/// The destinations the user can choose from.
internal static IReadOnlyList SelectDestinations(Components sender, Func isVisible) => Enum.GetValues()
.Where(destination => destination.AllowSendTo())
.Where(destination => destination != sender || destination is Components.CHAT)
.Where(isVisible)
.OrderBy(destination => destination.Name().Length)
.ToArray();
private bool IsVisible(Components destination) => settingsManager.IsAssistantVisible(destination, withLogging: false, requiredPreviewFeature: destination.RequiredPreviewFeature());
}