Added a Send to menu to every chat message and kept restrictions when sending content on (#1124)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-28 13:28:30 +01:00
1 parent d06688ca08
commit bf54b8cd0d
73 files changed
+1923 -390

No files matched your search

@@ -4,6 +4,7 @@ using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Dialogs.Settings;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.AssistantLaunch;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
@@ -48,6 +49,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
[Inject]
protected AssistantSessionService AssistantSessionService { get; init; } = null!;
[Inject]
private AssistantLaunchService AssistantLaunchService { get; init; } = null!;
/// <summary>
/// Gets the job service used to run assistant-created chats independently from the assistant UI.
/// </summary>
@@ -162,6 +166,44 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual ChatThread ConvertToChatThread => this.CreateSendToChatThread();
/// <summary>
/// Takes over a text which another assistant or a chat sent to this assistant.
/// </summary>
/// <remarks>
/// Every assistant which content may be sent to puts the text into its main field, see
/// ComponentsExtensions.AllowSendTo. It comes before the preselected values, so a field which those only fill
/// when empty keeps the text. An assistant which allows sending to it but takes nothing would leave the text
/// lost without a word, which is why a test checks that each one overrides this.
/// </remarks>
/// <param name="payload">The text sent.</param>
protected virtual void ApplyTextLaunchPayload(TextLaunchPayload payload) => this.Logger.LogError("The assistant '{Component}' allows sending text to it, but it does not take the text.", this.Component);
/// <summary>
/// What the text sent to this assistant demands, because of where it came from.
/// </summary>
/// <remarks>
/// An answer written from mails demands here what it demanded in its chat. The thread of the
/// assistant is created anew with every run, so the restrictions live here and go onto every thread
/// this assistant runs, see AssignRuntimeIdentity, and onto every tool list, see GetRunnableToolIds.
/// They stay until the form is reset, which removes the text as well.
/// </remarks>
private ChatRestrictions transferredRestrictions = ChatRestrictions.NONE;
/// <summary>
/// What everything this assistant holds demands: the text sent to it and what its current thread read.
/// </summary>
private ChatRestrictions CurrentRestrictions => this.ChatThread?.CaptureRestrictions().StricterOf(this.transferredRestrictions) ?? this.transferredRestrictions;
/// <summary>
/// Keeps what the current result demands, before the assistant puts that result into its own input.
/// </summary>
/// <remarks>
/// Call this before clearing the thread: the result was written from what this thread read, and the
/// web addresses in it were written by a model. Both stay with the input until the form is reset.
/// </remarks>
/// <param name="resultText">The result which becomes the new input.</param>
protected void TakeOverRestrictionsOfResult(string resultText) => this.transferredRestrictions = this.CurrentRestrictions.WithWebAddressesWrittenByModelIn(resultText);
private protected virtual RenderFragment? HeaderActions => null;
private protected virtual RenderFragment? AfterResultContent => null;
@@ -213,6 +255,13 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected override async Task OnInitializedAsync()
{
// A text sent here comes first, so that the preselected values below only fill what it left empty:
if (AssistantLaunchService.TakeTextPayload(this.Component) is { } textLaunchPayload)
{
this.transferredRestrictions = textLaunchPayload.Restrictions;
this.ApplyTextLaunchPayload(textLaunchPayload);
}
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await base.OnInitializedAsync();
@@ -276,25 +325,30 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
private string SubmitButtonStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).StyleBorder(this.SettingsManager) : string.Empty;
private IReadOnlyList<Tools.Components> VisibleSendToAssistants => Enum.GetValues<AIStudio.Tools.Components>()
.Where(this.CanSendToAssistant)
.OrderBy(component => component.Name().Length)
.ToArray();
private IReadOnlyList<Tools.Components> VisibleSendToAssistants => this.AssistantLaunchService.GetDestinationsFor(this.Component);
protected string? ValidatingProvider(AIStudio.Settings.Provider provider)
{
if(provider.UsedLLMProvider == LLMProviders.NONE)
return this.TB("Please select a provider.");
// A provider which nothing else rules out may still be kept from the text sent here, because of where it came from:
if (!this.transferredRestrictions.IsLLMProviderAllowed(provider) && ChatRestrictions.NONE.IsLLMProviderAllowed(provider))
return this.TB("This provider may not see the text sent to this assistant, because of where the text came from. Please select another provider.");
return null;
}
private async Task Start()
{
//
// A provider which may not see the text sent here would fail the run without a word, see
// ContentText.CreateFromProviderAsync, so the user hears about it before the run starts:
//
await this.RefreshProviderSelectionFromConfigurationAsync();
if (this.ProviderSettings == Settings.Provider.NONE)
if (this.ProviderSettings == Settings.Provider.NONE || this.ValidatingProvider(this.ProviderSettings) is not null)
{
await this.ReportMissingProviderAsync();
await this.ReportProviderIssueAsync();
return;
}
@@ -367,16 +421,17 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
}
/// <summary>
/// Tells the user that the assistant cannot start without a provider, together with whatever else is missing.
/// Tells the user that the assistant cannot start with the selected provider, together with whatever else is missing.
/// </summary>
/// <remarks>
/// Most assistants validate their form only in their submit action, which never runs without a
/// provider. Validating here marks the provider selection and lists the other issues as well.
/// The form does not report the missing provider when it holds no provider selection, as with an
/// assistant plugin without one, or when its selection still shows the provider which the refresh
/// at the start has just dropped. Then the issue is added on its own.
/// Either no provider is selected, or the selected one may not see the text sent to this assistant,
/// see ValidatingProvider. Most assistants validate their form only in their submit action, which
/// never runs without a usable provider. Validating here marks the provider selection and lists the
/// other issues as well. The form does not report the issue when it holds no provider selection, as
/// with an assistant plugin without one, or when its selection still shows the provider which the
/// refresh at the start has just dropped. Then the issue is added on its own.
/// </remarks>
private async Task ReportMissingProviderAsync()
private async Task ReportProviderIssueAsync()
{
if (this.Form is not null)
await this.Form.Validate();
@@ -466,16 +521,20 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
/// <summary>
/// Sets who runs the given thread: this assistant's component and, for an assistant plugin, its name.
/// It also puts the restrictions of a text sent to this assistant on the thread.
/// </summary>
/// <remarks>
/// Both decide which tools the thread may use and what its requests to self-hosted servers name. Every
/// way an assistant creates or sends a thread goes through here, so that none of them can forget one.
/// That is why the restrictions of a text sent here go onto the thread here, too: every thread of this
/// assistant may hold that text, its private threads for research included.
/// </remarks>
/// <param name="chatThread">The thread this assistant runs.</param>
protected void AssignRuntimeIdentity(ChatThread chatThread)
{
chatThread.RuntimeComponent = this.Component;
chatThread.RuntimeAssistantName = this.RuntimeAssistantName;
chatThread.RequireRestrictions(this.transferredRestrictions);
}
private Task RefreshProviderSelectionFromConfigurationAsync()
@@ -515,9 +574,12 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
/// the user a tool for good. The same goes for a tool which a mailbox the thread read from
/// keeps back.
/// </remarks>
/// <param name="outboundDataRestriction">Where the thread the tools run in may still send data, see ChatThread.RequiredOutboundDataRestriction.</param>
/// <param name="outboundDataRestriction">Where the thread the tools run in may still send data, see ChatThread.RequiredOutboundDataRestriction. A text sent to this assistant may demand more, and then that counts.</param>
protected HashSet<string> GetRunnableToolIds(OutboundDataRestriction outboundDataRestriction)
{
// Every thread of this assistant may hold the text sent to it, so what that text demands holds whoever asks:
outboundDataRestriction = outboundDataRestriction.StricterOf(this.transferredRestrictions.OutboundData.Restriction);
if (this.AssistantManagedToolIds is not null)
return this.ToolRegistry.FilterToolIdsForProvider(this.ProviderSettings, this.AssistantManagedToolIds, outboundDataRestriction);
@@ -834,7 +896,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected async Task SendToAssistant(Tools.Components destination, SendToButton sendToButton)
{
if (!this.CanSendToAssistant(destination))
if (!this.VisibleSendToAssistants.Contains(destination))
return;
var contentToSend = sendToButton == default ? string.Empty : sendToButton.UseResultingContentBlockData switch
@@ -847,57 +909,33 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
},
};
var sendToData = destination.GetData();
if (destination.HasSingleSessionSlot() && this.AssistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && snapshot.Key.Component == destination))
// The chat starts from the conversation of this assistant, unless it only takes the text for the next message:
if (destination is Tools.Components.CHAT && !sendToButton.SendToChatAsInput)
{
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Apps, this.TB("This assistant is already running. AI Studio opens the running session instead.")));
this.NavigationManager.NavigateTo(sendToData.Route);
var convertedChatThread = this.ConvertToChatThread;
convertedChatThread = convertedChatThread with { SelectedProvider = this.ProviderSettings.Id };
//
// The chat holds what this assistant held, whichever thread the assistant builds it from: some
// assistants start it from a new thread, which has read nothing, or from none at all. The copy
// above is the chat's own, so tightening it leaves the thread of this assistant as it is:
//
convertedChatThread.RequireRestrictions(this.CurrentRestrictions);
// Some assistants start the chat under an id of its own, so what their answers received goes along. A chat without an id received nothing:
if (this.ChatThread is { } assistantChat && convertedChatThread.ChatId != Guid.Empty && assistantChat.ChatId != convertedChatThread.ChatId)
await ChatProvenance.CopyChatAsync(assistantChat.ChatId, convertedChatThread.ChatId);
MessageBus.INSTANCE.DeferMessage(this, Event.SEND_TO_CHAT, new ChatStartRequest(convertedChatThread));
this.NavigationManager.NavigateTo(Routes.CHAT);
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 this.AssistantSessionService.ClearInactiveSessionsForComponentAsync(destination);
switch (destination)
{
case Tools.Components.CHAT:
if (sendToButton.SendToChatAsInput)
MessageBus.INSTANCE.DeferMessage(this, Event.SEND_TO_CHAT_INPUT, contentToSend);
else
{
var convertedChatThread = this.ConvertToChatThread;
convertedChatThread = convertedChatThread with { SelectedProvider = this.ProviderSettings.Id };
// Some assistants start the chat under an id of its own, so what their answers received goes along. A chat without an id received nothing:
if (this.ChatThread is { } assistantChat && convertedChatThread.ChatId != Guid.Empty && assistantChat.ChatId != convertedChatThread.ChatId)
await ChatProvenance.CopyChatAsync(assistantChat.ChatId, convertedChatThread.ChatId);
MessageBus.INSTANCE.DeferMessage(this, sendToData.Event, new ChatStartRequest(convertedChatThread));
}
break;
default:
MessageBus.INSTANCE.DeferMessage(this, sendToData.Event, contentToSend);
break;
}
this.NavigationManager.NavigateTo(sendToData.Route);
// A model wrote what an assistant sends, even when it falls back to the input, so it gets filtered, and none of its addresses counts as given:
var payload = await this.AssistantLaunchService.CreateTextPayloadAsync(contentToSend, writtenByModel: true, this.CurrentRestrictions, this.Title);
await this.AssistantLaunchService.LaunchAsync(destination, payload);
}
private bool CanSendToAssistant(Tools.Components component)
{
if (!component.AllowSendTo())
return false;
return this.SettingsManager.IsAssistantVisible(
component,
withLogging: false,
requiredPreviewFeature: component.RequiredPreviewFeature());
}
private async Task InnerResetForm()
{
if ((this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey)?.IsActive ?? false)
@@ -908,6 +946,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
this.MediaTranscriptionService.ClearOwnerState(this.CurrentMediaImportOwner);
this.assistantSessionId = null;
this.ClearConversationState();
// The text sent here is gone with the form, and what it demanded goes with it:
this.transferredRestrictions = ChatRestrictions.NONE;
this.ProviderSettings = Settings.Provider.NONE;
await this.JsRuntime.ClearDiv(BEFORE_RESULT_DIV_ID);
@@ -1149,6 +1190,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
state.Set(INPUT_ISSUES_STATE_KEY, this.InputIssues);
state.Set(IS_PROCESSING_STATE_KEY, this.IsProcessing);
state.Set(SELECTED_TOOL_IDS_STATE_KEY, this.SelectedToolIds);
state.Set(TRANSFERRED_RESTRICTIONS_STATE_KEY, this.transferredRestrictions);
this.CaptureCustomAssistantSessionState(state);
return state.ToDictionary();
@@ -1177,6 +1219,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
reader.Restore(INPUT_ISSUES_STATE_KEY, value => this.InputIssues = value);
reader.Restore(IS_PROCESSING_STATE_KEY, value => this.IsProcessing = value);
reader.Restore(SELECTED_TOOL_IDS_STATE_KEY, value => this.SelectedToolIds = this.ToolRegistry.NormalizeSelection(value));
reader.Restore(TRANSFERRED_RESTRICTIONS_STATE_KEY, value => this.transferredRestrictions = value);
this.RestoreCustomAssistantSessionState(reader);
}