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); }