2024-07-14 21:46:17 +02:00
using AIStudio.Chat ;
using AIStudio.Provider ;
using AIStudio.Settings ;
2026-10-04 12:26:26 +02:00
using AIStudio.Settings.DataModel ;
2026-02-01 14:50:19 +01:00
using AIStudio.Dialogs.Settings ;
2026-07-03 15:02:20 +02:00
using AIStudio.Tools.AIJobs ;
2026-10-28 13:28:30 +01:00
using AIStudio.Tools.AssistantLaunch ;
2026-07-03 15:02:20 +02:00
using AIStudio.Tools.AssistantSessions ;
2026-07-15 12:53:30 +02:00
using AIStudio.Tools.Media ;
2025-02-15 15:41:12 +01:00
using AIStudio.Tools.Services ;
2026-09-04 15:48:07 +02:00
using AIStudio.Tools.ToolCallingSystem ;
2024-07-14 21:46:17 +02:00
using Microsoft.AspNetCore.Components ;
2025-01-01 15:49:27 +01:00
using MudBlazor.Utilities ;
using Timer = System . Timers . Timer ;
2024-09-01 20:10:03 +02:00
2025-03-12 11:31:01 +01:00
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
2024-08-21 08:30:01 +02:00
namespace AIStudio.Assistants ;
2024-07-14 21:46:17 +02:00
2025-04-27 09:06:05 +02:00
public abstract partial class AssistantBase < TSettings > : AssistantLowerBase where TSettings : IComponent
2024-07-14 21:46:17 +02:00
{
2025-03-12 11:31:01 +01:00
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
2024-07-14 21:46:17 +02:00
[Inject]
protected IJSRuntime JsRuntime { get ; init ; } = null !;
2026-08-02 21:41:12 +02:00
2024-08-13 08:57:58 +02:00
[Inject]
2024-09-01 20:10:03 +02:00
protected RustService RustService { get ; init ; } = null !;
2026-09-04 15:48:07 +02:00
2026-10-16 20:42:04 +02:00
[Inject]
protected AIGeneratedContentDisclosureService DisclosureService { get ; init ; } = null !;
2026-09-04 15:48:07 +02:00
[Inject]
protected ToolRegistry ToolRegistry { get ; init ; } = null !;
2024-08-13 08:57:58 +02:00
2024-08-18 12:32:18 +02:00
[Inject]
protected NavigationManager NavigationManager { get ; init ; } = null !;
2024-09-01 20:10:03 +02:00
[Inject]
2025-03-12 11:31:01 +01:00
protected ILogger < AssistantBase < TSettings >> Logger { get ; init ; } = null !;
2024-09-01 20:10:03 +02:00
2024-09-15 12:30:07 +02:00
[Inject]
private MudTheme ColorTheme { get ; init ; } = null !;
2026-07-03 15:02:20 +02:00
[Inject]
protected AssistantSessionService AssistantSessionService { get ; init ; } = null !;
2026-10-28 13:28:30 +01:00
[Inject]
private AssistantLaunchService AssistantLaunchService { get ; init ; } = null !;
2026-07-03 15:02:20 +02:00
/// <summary>
/// Gets the job service used to run assistant-created chats independently from the assistant UI.
/// </summary>
[Inject]
protected AIJobService AIJobService { get ; init ; } = null !;
2026-07-15 12:53:30 +02:00
[Inject]
protected MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2024-09-15 12:30:07 +02:00
2024-07-14 21:46:17 +02:00
protected abstract string Title { get ; }
protected abstract string Description { get ; }
protected abstract string SystemPrompt { get ; }
2026-02-01 14:50:19 +01:00
protected abstract Tools . Components Component { get ; }
2026-10-04 14:44:16 +02:00
/// <summary>
/// The name of the assistant plugin, which is only set by plugin-provided assistants.
/// </summary>
protected virtual string RuntimeAssistantName => string . Empty ;
2024-09-04 15:44:23 +02:00
2026-07-03 15:02:20 +02:00
protected virtual Func < string > Result2Copy => () => this . ResultingContentBlock is null ? string . Empty : this . ResultingContentBlock . Content switch
2024-08-23 08:59:30 +02:00
{
ContentText textBlock => textBlock . Text ,
_ => string . Empty ,
};
2024-08-18 21:48:35 +02:00
2026-09-23 15:02:33 +02:00
/// <summary>
/// What an export of the result is named after, which the save dialog suggests as file name.
/// An assistant whose result is about something more specific than the assistant itself names that.
/// </summary>
protected virtual string ExportFileName => this . Title ;
2025-01-01 15:49:27 +01:00
protected abstract void ResetForm ();
2024-08-18 21:48:35 +02:00
protected abstract bool MightPreselectValues ();
2024-07-14 21:46:17 +02:00
2024-09-11 23:08:02 +02:00
protected abstract string SubmitText { get ; }
protected abstract Func < Task > SubmitAction { get ; }
2025-03-12 11:31:01 +01:00
2024-09-11 23:08:02 +02:00
protected virtual bool SubmitDisabled => false ;
2024-07-14 21:46:17 +02:00
private protected virtual RenderFragment ? Body => null ;
2026-07-05 17:48:00 +02:00
private protected virtual RenderFragment ? AfterSubmitContent => null ;
private protected virtual RenderFragment ? BelowSubmitContent => null ;
2024-08-13 08:57:58 +02:00
protected virtual bool ShowResult => true ;
2025-01-01 15:49:27 +01:00
protected virtual bool ShowEntireChatThread => false ;
2024-09-08 21:01:51 +02:00
protected virtual bool AllowProfiles => true ;
2024-09-09 15:08:16 +02:00
protected virtual bool ShowProfileSelection => true ;
2024-08-13 20:53:09 +02:00
protected virtual bool ShowDedicatedProgress => false ;
2026-10-25 18:52:39 +01:00
/// <summary>
/// What the assistant is doing right now, shown below the dedicated progress bar; empty for no text.
/// </summary>
protected virtual string ProgressText => string . Empty ;
2024-10-28 15:41:00 +01:00
protected virtual bool ShowSendTo => true ;
protected virtual bool ShowCopyResult => true ;
2026-10-16 20:42:04 +02:00
/// <summary>
/// Whether "Copy result", and copying or exporting the answers this assistant shows, end them with the
/// notice that AI generated them, when that is switched on.
/// </summary>
/// <remarks>
/// An assistant whose result is meant for a program or another model rather than for a reader turns
/// this off, because one sentence more would change what the result does.
/// </remarks>
protected virtual bool AddsAIGeneratedContentDisclosure => true ;
2024-10-28 15:41:00 +01:00
protected virtual bool ShowReset => true ;
2024-08-13 08:57:58 +02:00
2026-10-25 18:52:39 +01:00
/// <summary>
/// Whether the system prompt starts with the current date and time.
/// </summary>
/// <remarks>
/// Off for most assistants, whose results do not depend on the date. An assistant which writes
/// the date into its result, or plans around it, turns this on rather than guessing the date.
/// </remarks>
protected virtual bool IncludeDateTime => false ;
2026-04-16 16:12:45 +02:00
protected virtual string? SendToChatVisibleUserPromptPrefix => null ;
protected virtual string? SendToChatVisibleUserPromptContent => null ;
protected virtual string? SendToChatVisibleUserPromptText
{
get
{
if ( string . IsNullOrWhiteSpace ( this . SendToChatVisibleUserPromptPrefix ))
return null ;
if ( string . IsNullOrWhiteSpace ( this . SendToChatVisibleUserPromptContent ))
return this . SendToChatVisibleUserPromptPrefix ;
return $"""
{this.SendToChatVisibleUserPromptPrefix}
{this.SendToChatVisibleUserPromptContent}
""" ;
}
}
protected virtual ChatThread ConvertToChatThread => this . CreateSendToChatThread ();
2024-08-18 21:48:35 +02:00
2026-10-28 13:28:30 +01:00
/// <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 );
2026-04-15 19:40:53 +02:00
private protected virtual RenderFragment ? HeaderActions => null ;
private protected virtual RenderFragment ? AfterResultContent => null ;
2024-08-18 12:32:18 +02:00
protected virtual IReadOnlyList < IButtonData > FooterButtons => [];
2026-02-01 14:50:19 +01:00
protected virtual bool HasSettingsPanel => typeof ( TSettings ) != typeof ( NoSettingsPanel );
2024-07-16 10:28:13 +02:00
2026-09-04 15:48:07 +02:00
protected HashSet < string > SelectedToolIds = [];
2026-07-03 15:02:20 +02:00
private readonly Timer formChangeTimer = new ( TimeSpan . FromSeconds ( 1.6 ));
2026-09-04 15:48:07 +02:00
2026-04-16 16:48:47 +02:00
protected MudForm ? Form ;
protected CancellationTokenSource ? CancellationTokenSource ;
2026-07-03 15:02:20 +02:00
private bool isDisposed ;
private AssistantSessionKey assistantSessionKey ;
2026-07-15 12:53:30 +02:00
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner . ForAssistant ( this . assistantSessionKey );
2026-07-03 15:02:20 +02:00
private Guid ? assistantSessionId ;
2026-10-25 22:10:38 +01:00
/// <summary>
/// When the running session started, which the time below the progress bar counts from; null while
/// no session runs. It comes from the session, so an instance which returns to a running session
/// goes on counting where the first one was.
/// </summary>
private DateTimeOffset ? processingStartedAt ;
2026-07-03 15:02:20 +02:00
private AssistantSessionSnapshot ? pendingRenderedAssistantSessionSnapshot ;
2026-10-25 21:09:23 +01:00
private AssistantSessionSnapshot ? lastAttachedAssistantSessionSnapshot ;
2025-04-26 18:55:23 +02:00
2026-07-03 15:02:20 +02:00
/// <summary>
/// Gets whether the Blazor component instance has already been disposed.
/// </summary>
protected bool IsAssistantComponentDisposed => this . isDisposed ;
/// <summary>
/// Gets whether this component has attached an assistant session snapshot.
/// </summary>
protected bool HasAssistantSession => this . assistantSessionId is not null ;
2026-07-15 12:53:30 +02:00
/// <summary>Gets whether this assistant currently owns active media work.</summary>
protected bool IsMediaImportBusy => this . MediaTranscriptionService . IsBusy ( this . CurrentMediaImportOwner );
2026-07-03 15:02:20 +02:00
/// <summary>
/// Gets the assistant-specific identifier used to distinguish session slots.
/// </summary>
protected virtual string AssistantSessionInstanceId => this . GetType (). FullName ?? this . Component . ToString ();
2024-07-14 21:46:17 +02:00
#region Overrides of ComponentBase
2024-09-04 15:44:23 +02:00
protected override async Task OnInitializedAsync ()
{
2026-10-28 13:28:30 +01:00
// 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 );
}
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged += this . OnMediaImportStateChanged ;
2025-04-27 16:13:15 +02:00
await base . OnInitializedAsync ();
2026-03-14 10:37:31 +01:00
if (! this . SettingsManager . IsAssistantVisible ( this . Component , assistantName : this . Title ))
{
this . Logger . LogInformation ( "Assistant '{AssistantTitle}' is hidden. Redirecting to the assistants overview." , this . Title );
this . NavigationManager . NavigateTo ( Routes . ASSISTANTS );
return ;
}
2025-04-27 16:13:15 +02:00
2025-01-01 15:49:27 +01:00
this . formChangeTimer . AutoReset = false ;
2026-08-25 18:53:09 +02:00
//
// Mind the missing async here: a timer hands its elapsed event to a thread pool thread, where an
// async handler has nobody to hand its exception to. Such an exception is not merely unobserved,
// it is unhandled, and it takes the app down with it. Observing the task keeps it contained.
//
this . formChangeTimer . Elapsed += ( _ , _ ) =>
2025-01-01 15:49:27 +01:00
{
this . formChangeTimer . Stop ();
2026-11-04 14:04:10 +01:00
this . InvokeAsync ( this . OnFormChange ). Observe ( $"{nameof(AssistantBase<>)}: handling a form change" );
2025-01-01 15:49:27 +01:00
};
2024-09-04 15:44:23 +02:00
this . MightPreselectValues ();
2026-04-16 16:48:47 +02:00
this . ProviderSettings = this . SettingsManager . GetPreselectedProvider ( this . Component );
this . CurrentProfile = this . SettingsManager . GetPreselectedProfile ( this . Component );
this . CurrentChatTemplate = this . SettingsManager . GetPreselectedChatTemplate ( this . Component );
2026-10-04 12:26:26 +02:00
this . SelectedToolIds = this . ToolRegistry . GetDefaultToolIds ( this . Component );
2026-08-11 20:14:21 +02:00
await this . OnDefaultsAppliedAsync ();
2026-07-03 15:02:20 +02:00
this . assistantSessionKey = new ( this . Component , this . AssistantSessionInstanceId );
await this . AttachAssistantSessionIfAvailable ();
2026-07-15 12:53:30 +02:00
await this . ConsumeMediaOutcomeAsync ();
2024-09-04 15:44:23 +02:00
}
2024-07-15 16:56:28 +02:00
protected override async Task OnParametersSetAsync ()
{
// Configure the spellchecking for the user input:
this . SettingsManager . InjectSpellchecking ( USER_INPUT_ATTRIBUTES );
await base . OnParametersSetAsync ();
}
2024-07-14 21:46:17 +02:00
protected override async Task OnAfterRenderAsync ( bool firstRender )
{
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
2026-10-31 10:48:20 +01:00
if ( firstRender && this . Form is not null )
await this . Form . ResetValidationAsync ();
2026-07-03 15:02:20 +02:00
if ( this . pendingRenderedAssistantSessionSnapshot is { } snapshot )
{
this . pendingRenderedAssistantSessionSnapshot = null ;
await this . OnAssistantSessionRenderedAsync ( snapshot );
}
2024-07-14 21:46:17 +02:00
await base . OnAfterRenderAsync ( firstRender );
}
#endregion
2024-09-11 23:08:02 +02:00
2026-11-04 14:04:10 +01:00
private string TB ( string fallbackEN ) => this . T ( fallbackEN , typeof ( AssistantBase <>). Namespace , nameof ( AssistantBase <>));
2025-04-27 16:13:15 +02:00
2026-06-21 11:52:02 +02:00
private string SubmitButtonStyle => this . SettingsManager . ConfigurationData . Confidence . ShowProviderConfidence ? this . ProviderSettings . UsedLLMProvider . GetConfidence ( this . SettingsManager ). StyleBorder ( this . SettingsManager ) : string . Empty ;
2026-03-14 10:37:31 +01:00
2026-10-28 13:28:30 +01:00
private IReadOnlyList < Tools . Components > VisibleSendToAssistants => this . AssistantLaunchService . GetDestinationsFor ( this . Component );
2024-07-14 21:46:17 +02:00
2024-07-16 20:06:04 +02:00
protected string? ValidatingProvider ( AIStudio . Settings . Provider provider )
{
2024-09-13 21:50:00 +02:00
if ( provider . UsedLLMProvider == LLMProviders . NONE )
2025-05-17 14:22:10 +02:00
return this . TB ( "Please select a provider." );
2026-10-28 13:28:30 +01:00
// 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." );
2024-07-16 20:06:04 +02:00
return null ;
}
2025-01-01 15:49:27 +01:00
2025-04-26 18:55:23 +02:00
private async Task Start ()
{
2026-10-28 13:28:30 +01:00
//
// 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:
//
2026-09-04 15:48:07 +02:00
await this . RefreshProviderSelectionFromConfigurationAsync ();
2026-10-28 13:28:30 +01:00
if ( this . ProviderSettings == Settings . Provider . NONE || this . ValidatingProvider ( this . ProviderSettings ) is not null )
2026-10-25 20:44:23 +01:00
{
2026-10-28 13:28:30 +01:00
await this . ReportProviderIssueAsync ();
2026-09-04 15:48:07 +02:00
return ;
2026-10-25 20:44:23 +01:00
}
2026-09-04 15:48:07 +02:00
2026-07-15 12:53:30 +02:00
if ( this . MediaTranscriptionService . IsBusy ( this . CurrentMediaImportOwner ))
return ;
2026-07-03 15:02:20 +02:00
var activeSession = this . AssistantSessionService . TryGetSnapshot ( this . assistantSessionKey );
if ( activeSession ?. IsActive ?? false )
{
await this . AttachAssistantSession ( activeSession , restoreClientOnlyContent : true );
return ;
}
this . CancellationTokenSource = new ();
this . IsProcessing = true ;
var startedSession = await this . AssistantSessionService . TryBeginAsync ( this . assistantSessionKey , this . Title , this . CancellationTokenSource , this . ChatThread , this . CaptureAssistantSessionState (), this );
if ( startedSession . IsActive is not true || startedSession . Key != this . assistantSessionKey )
{
this . CancellationTokenSource . Dispose ();
this . CancellationTokenSource = null ;
return ;
}
this . assistantSessionId = startedSession . SessionId ;
2026-10-25 22:10:38 +01:00
this . processingStartedAt = startedSession . StartedAt ;
2026-07-03 15:02:20 +02:00
await this . RefreshAssistantUIAsync ();
var sessionStatus = AssistantSessionStatus . COMPLETED ;
var errorMessage = string . Empty ;
try
2025-04-26 18:55:23 +02:00
{
await this . SubmitAction ();
2026-07-03 15:02:20 +02:00
if ( this . CancellationTokenSource ?. IsCancellationRequested ?? false )
sessionStatus = AssistantSessionStatus . CANCELED ;
}
catch ( OperationCanceledException )
{
sessionStatus = AssistantSessionStatus . CANCELED ;
}
catch ( ProviderRequestException e )
{
sessionStatus = AssistantSessionStatus . FAILED ;
errorMessage = e . UserMessage ;
this . Logger . LogError ( e , "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'" , this . Title , e . StatusCode , e . ReasonPhrase , e . ResponseBody );
await MessageBus . INSTANCE . SendError ( new ( Icons . Material . Filled . CloudOff , e . UserMessage ));
}
catch ( Exception e )
{
sessionStatus = AssistantSessionStatus . FAILED ;
errorMessage = e . Message ;
this . Logger . LogError ( e , "The assistant session '{AssistantTitle}' failed." , this . Title );
await MessageBus . INSTANCE . SendError ( new ( Icons . Material . Filled . Stream , string . Format ( this . TB ( "The assistant failed. The message is: '{0}'" ), e . Message )));
}
finally
{
this . IsProcessing = false ;
2026-10-25 22:10:38 +01:00
this . processingStartedAt = null ;
2026-07-03 15:02:20 +02:00
var sessionCancellationTokenSource = this . CancellationTokenSource ;
this . CancellationTokenSource = null ;
if ( this . assistantSessionId is { } sessionId )
{
await this . AssistantSessionService . CompleteAsync ( this . assistantSessionKey , sessionId , sessionStatus , errorMessage , this . ChatThread , this . CaptureAssistantSessionState (), this );
if (! this . isDisposed )
_ = this . AssistantSessionService . TryTakeInactiveSnapshot ( this . assistantSessionKey );
}
sessionCancellationTokenSource ?. Dispose ();
await this . RefreshAssistantUIAsync ();
2025-04-26 18:55:23 +02:00
}
}
2026-10-25 20:44:23 +01:00
/// <summary>
2026-10-28 13:28:30 +01:00
/// Tells the user that the assistant cannot start with the selected provider, together with whatever else is missing.
2026-10-25 20:44:23 +01:00
/// </summary>
/// <remarks>
2026-10-28 13:28:30 +01:00
/// 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.
2026-10-25 20:44:23 +01:00
/// </remarks>
2026-10-28 13:28:30 +01:00
private async Task ReportProviderIssueAsync ()
2026-10-25 20:44:23 +01:00
{
if ( this . Form is not null )
2026-10-31 10:48:20 +01:00
await this . Form . ValidateAsync ();
2026-10-25 20:44:23 +01:00
if ( this . ValidatingProvider ( this . ProviderSettings ) is { } issue && ! this . InputIssues . Contains ( issue ))
this . AddInputIssue ( issue );
}
2025-01-01 15:49:27 +01:00
private void TriggerFormChange ( FormFieldChangedEventArgs _ )
{
this . formChangeTimer . Stop ();
this . formChangeTimer . Start ();
}
2024-07-16 20:06:04 +02:00
2025-01-01 15:49:27 +01:00
/// <summary>
/// This method is called after any form field has changed.
/// </summary>
/// <remarks>
/// This method is called after a delay of 1.6 seconds. This is to prevent
/// the method from being called too often. This method is called after
/// the user has stopped typing or selecting options.
/// </remarks>
protected virtual Task OnFormChange () => Task . CompletedTask ;
2026-08-11 20:14:21 +02:00
/// <summary>
/// Allows assistants to finish asynchronous work after their configured defaults were applied.
/// </summary>
protected virtual Task OnDefaultsAppliedAsync () => Task . CompletedTask ;
2025-01-01 15:49:27 +01:00
/// <summary>
/// Add an issue to the UI.
/// </summary>
/// <param name="issue">The issue to add.</param>
protected void AddInputIssue ( string issue )
{
2026-07-03 15:02:20 +02:00
Array . Resize ( ref this . InputIssues , this . InputIssues . Length + 1 );
this . InputIssues [^ 1 ] = issue ;
2026-04-16 16:48:47 +02:00
this . InputIsValid = false ;
2026-11-04 14:04:10 +01:00
this . RefreshAssistantUIAsync (). Observe ( $"{nameof(AssistantBase<>)}: rendering an added input issue" );
2025-01-01 15:49:27 +01:00
}
2026-02-01 14:50:19 +01:00
/// <summary>
/// Clear all input issues.
/// </summary>
protected void ClearInputIssues ()
{
2026-07-03 15:02:20 +02:00
this . InputIssues = [];
2026-04-16 16:48:47 +02:00
this . InputIsValid = true ;
2026-11-04 14:04:10 +01:00
this . RefreshAssistantUIAsync (). Observe ( $"{nameof(AssistantBase<>)}: rendering cleared input issues" );
2026-02-01 14:50:19 +01:00
}
2025-01-01 15:49:27 +01:00
2024-07-14 21:46:17 +02:00
protected void CreateChatThread ()
{
2026-04-16 16:48:47 +02:00
this . ChatThread = new ()
2024-07-14 21:46:17 +02:00
{
2026-10-25 18:52:39 +01:00
IncludeDateTime = this . IncludeDateTime ,
2026-04-16 16:48:47 +02:00
SelectedProvider = this . ProviderSettings . Id ,
SelectedProfile = this . AllowProfiles ? this . CurrentProfile . Id : Profile . NO_PROFILE . Id ,
2025-01-02 14:50:54 +01:00
SystemPrompt = this . SystemPrompt ,
2024-07-14 21:46:17 +02:00
WorkspaceId = Guid . Empty ,
ChatId = Guid . NewGuid (),
2025-05-17 14:22:10 +02:00
Name = string . Format ( this . TB ( "Assistant - {0}" ), this . Title ),
2024-07-14 21:46:17 +02:00
Blocks = [],
};
2026-10-04 14:44:16 +02:00
this . AssignRuntimeIdentity ( this . ChatThread );
2024-07-14 21:46:17 +02:00
}
2024-10-28 15:41:00 +01:00
protected Guid CreateChatThread ( Guid workspaceId , string name )
{
var chatId = Guid . NewGuid ();
2026-04-16 16:48:47 +02:00
this . ChatThread = new ()
2024-10-28 15:41:00 +01:00
{
2026-10-25 18:52:39 +01:00
IncludeDateTime = this . IncludeDateTime ,
2026-04-16 16:48:47 +02:00
SelectedProvider = this . ProviderSettings . Id ,
SelectedProfile = this . AllowProfiles ? this . CurrentProfile . Id : Profile . NO_PROFILE . Id ,
2025-01-02 14:50:54 +01:00
SystemPrompt = this . SystemPrompt ,
2024-10-28 15:41:00 +01:00
WorkspaceId = workspaceId ,
ChatId = chatId ,
Name = name ,
Blocks = [],
};
2026-10-04 14:44:16 +02:00
this . AssignRuntimeIdentity ( this . ChatThread );
2024-10-28 15:41:00 +01:00
return chatId ;
}
2026-02-01 14:50:19 +01:00
2026-10-04 14:44:16 +02:00
/// <summary>
/// Sets who runs the given thread: this assistant's component and, for an assistant plugin, its name.
2026-10-28 13:28:30 +01:00
/// It also puts the restrictions of a text sent to this assistant on the thread.
2026-10-04 14:44:16 +02:00
/// </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.
2026-10-28 13:28:30 +01:00
/// 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.
2026-10-04 14:44:16 +02:00
/// </remarks>
/// <param name="chatThread">The thread this assistant runs.</param>
protected void AssignRuntimeIdentity ( ChatThread chatThread )
{
chatThread . RuntimeComponent = this . Component ;
chatThread . RuntimeAssistantName = this . RuntimeAssistantName ;
2026-10-28 13:28:30 +01:00
chatThread . RequireRestrictions ( this . transferredRestrictions );
2026-10-04 14:44:16 +02:00
}
2026-09-04 15:48:07 +02:00
private Task RefreshProviderSelectionFromConfigurationAsync ()
{
this . ProviderSettings = this . SettingsManager . GetPreselectedProvider ( this . Component , this . ProviderSettings . Id );
return Task . CompletedTask ;
}
2026-02-01 14:50:19 +01:00
protected virtual void ResetProviderAndProfileSelection ()
{
2026-04-16 16:48:47 +02:00
this . ProviderSettings = this . SettingsManager . GetPreselectedProvider ( this . Component );
this . CurrentProfile = this . SettingsManager . GetPreselectedProfile ( this . Component );
this . CurrentChatTemplate = this . SettingsManager . GetPreselectedChatTemplate ( this . Component );
2026-10-04 12:26:26 +02:00
this . SelectedToolIds = this . ToolRegistry . GetDefaultToolIds ( this . Component );
2026-09-04 15:48:07 +02:00
}
/// <summary>
/// The tools this assistant runs with when its own rules name them, instead of asking the user.
/// </summary>
/// <remarks>
/// Null is the normal case: the user picks the tools. An assistant whose configuration already
/// says which tools belong to a run — a document analysis policy, for instance — returns them
/// here. Its tool selection then disappears from the footer, because there is nothing left to
/// choose: whoever wrote the policy has decided, and a user working with a policy rolled out by
/// their organization gets it as configured.
/// </remarks>
protected virtual IReadOnlySet < string >? AssistantManagedToolIds => null ;
/// <summary>
/// The tools this assistant may hand to a model with the provider it currently uses.
/// </summary>
/// <remarks>
/// Whether the tools come from the assistant's own rules or from the user, the provider filter
/// always has the last word: a tool asking for more confidence than the selected provider has
/// never reaches the model, no matter who put it on the list. That filter belongs here rather
/// than into the stored selection, because a provider with too little confidence must not cost
2026-10-04 12:26:26 +02:00
/// the user a tool for good. The same goes for a tool which a mailbox the thread read from
/// keeps back.
2026-09-04 15:48:07 +02:00
/// </remarks>
2026-10-28 13:28:30 +01:00
/// <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>
2026-10-04 12:26:26 +02:00
protected HashSet < string > GetRunnableToolIds ( OutboundDataRestriction outboundDataRestriction )
2026-09-04 15:48:07 +02:00
{
2026-10-28 13:28:30 +01:00
// 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 );
2026-09-04 15:48:07 +02:00
if ( this . AssistantManagedToolIds is not null )
2026-10-04 12:26:26 +02:00
return this . ToolRegistry . FilterToolIdsForProvider ( this . ProviderSettings , this . AssistantManagedToolIds , outboundDataRestriction );
2026-09-04 15:48:07 +02:00
// What the user cannot see, the assistant does not use:
if (! this . SettingsManager . IsToolSelectionVisible ( this . Component ))
return [];
2026-10-04 12:26:26 +02:00
return this . ToolRegistry . FilterToolIdsForProvider ( this . ProviderSettings , this . SelectedToolIds , outboundDataRestriction );
2026-09-04 15:48:07 +02:00
}
/// <summary>
/// Takes over a changed tool selection, no matter where the user made it.
/// </summary>
/// <remarks>
/// The footer offers one; an assistant may instead put the tools next to the setting they
/// belong to, as the batch processing does with its instructions. Both end up here.
/// </remarks>
protected Task SelectedToolIdsChanged ( HashSet < string > updatedToolIds )
{
2026-10-04 12:26:26 +02:00
this . SelectedToolIds = this . ToolRegistry . NormalizeSelection ( updatedToolIds );
2026-09-04 15:48:07 +02:00
return Task . CompletedTask ;
2026-02-01 14:50:19 +01:00
}
2024-07-14 21:46:17 +02:00
2025-12-30 18:30:32 +01:00
protected DateTimeOffset AddUserRequest ( string request , bool hideContentFromUser = false , params List < FileAttachment > attachments )
2024-07-14 21:46:17 +02:00
{
var time = DateTimeOffset . Now ;
2026-04-16 16:48:47 +02:00
this . LastUserPrompt = new ContentText
2025-02-10 18:49:22 +01:00
{
Text = request ,
2025-12-30 18:30:32 +01:00
FileAttachments = attachments ,
2025-02-10 18:49:22 +01:00
};
2026-04-16 16:48:47 +02:00
this . ChatThread !. Blocks . Add ( new ContentBlock
2024-07-14 21:46:17 +02:00
{
Time = time ,
ContentType = ContentType . TEXT ,
2024-10-28 15:41:00 +01:00
HideFromUser = hideContentFromUser ,
2024-07-14 21:46:17 +02:00
Role = ChatRole . USER ,
2026-04-16 16:48:47 +02:00
Content = this . LastUserPrompt ,
2024-07-14 21:46:17 +02:00
});
return time ;
}
2026-10-28 08:51:30 +01:00
/// <summary>
/// Asks the model for an answer to the conversation so far, and adds it as the result of this assistant.
/// </summary>
/// <param name="time">When the answer was asked for.</param>
/// <param name="hideContentFromUser">Whether the answer is hidden from the user, e.g., because it is JSON; while
/// it streams, only a changed progress text renders the form anew.</param>
/// <param name="useSelectedTools">Whether the model may use the tools the user selected. False for a request
/// which must rely on what the conversation holds already, e.g., one which reconciles results the tools
/// gathered before.</param>
/// <returns>The answer, or an empty string when the provider failed.</returns>
protected async Task < string > AddAIResponseAsync ( DateTimeOffset time , bool hideContentFromUser = false , bool useSelectedTools = true )
2024-07-14 21:46:17 +02:00
{
2026-04-16 16:48:47 +02:00
var manageCancellationLocally = this . CancellationTokenSource is null ;
this . CancellationTokenSource ??= new CancellationTokenSource ();
2025-05-17 19:17:29 +02:00
2024-07-14 21:46:17 +02:00
var aiText = new ContentText
{
// We have to wait for the remote
// for the content stream:
InitialRemoteWait = true ,
};
2026-10-25 18:52:39 +01:00
//
// The stream calls this for every chunk and waits for it. A checkpoint captures the whole
// state of the assistant and makes the main layout render as well, so both happen only now
// and then. When the stream is done, both happen in any case:
//
var lastRendering = DateTimeOffset . MinValue ;
var lastCheckpoint = DateTimeOffset . MinValue ;
string? lastProgressText = null ;
2026-07-03 15:02:20 +02:00
aiText . StreamingEvent = async () =>
{
2026-10-25 18:52:39 +01:00
var now = DateTimeOffset . Now ;
if ( now - lastCheckpoint >= STREAMING_CHECKPOINT_MIN_TIME )
{
lastCheckpoint = now ;
await this . CheckpointAssistantSession ();
}
if ( now - lastRendering < STREAMING_RENDER_MIN_TIME )
return ;
lastRendering = now ;
if (! hideContentFromUser )
{
await this . RefreshAssistantUIAsync ();
return ;
}
//
// A hidden answer shows nothing but the progress text, so the whole form renders only
// when that text changed. Thinking models stream for minutes before they write a word:
//
await this . RefreshAssistantUIAsync (() =>
{
var progressText = this . ProgressText ;
if ( progressText == lastProgressText )
return false ;
lastProgressText = progressText ;
return true ;
});
2026-07-03 15:02:20 +02:00
};
aiText . StreamingDone = async () =>
{
await this . CheckpointAssistantSession ();
await this . RefreshAssistantUIAsync ();
};
this . ResultingContentBlock = new ContentBlock
2024-07-14 21:46:17 +02:00
{
Time = time ,
ContentType = ContentType . TEXT ,
Role = ChatRole . AI ,
Content = aiText ,
2025-01-01 15:49:27 +01:00
HideFromUser = hideContentFromUser ,
2024-07-14 21:46:17 +02:00
};
2024-11-23 13:04:02 +01:00
2026-04-16 16:48:47 +02:00
if ( this . ChatThread is not null )
2024-11-23 13:04:02 +01:00
{
2026-07-03 15:02:20 +02:00
this . ChatThread . Blocks . Add ( this . ResultingContentBlock );
2026-04-16 16:48:47 +02:00
this . ChatThread . SelectedProvider = this . ProviderSettings . Id ;
2026-10-04 14:44:16 +02:00
this . AssignRuntimeIdentity ( this . ChatThread );
2026-09-04 15:48:07 +02:00
this . ChatThread . SelectedToolIds = [.. this . SelectedToolIds ];
2026-10-28 08:51:30 +01:00
this . ChatThread . RuntimeSelectedToolIds = useSelectedTools ? this . GetRunnableToolIds ( this . ChatThread . RequiredOutboundDataRestriction . Restriction ) : [];
2026-09-04 15:48:07 +02:00
this . ChatThread . RuntimeToolsAreAssistantManaged = this . AssistantManagedToolIds is not null ;
2024-11-23 13:04:02 +01:00
}
2026-07-03 15:02:20 +02:00
this . IsProcessing = true ;
await this . CheckpointAssistantSession ();
await this . RefreshAssistantUIAsync ();
2025-04-26 18:55:23 +02:00
2026-05-25 17:32:54 +02:00
try
{
// Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire
// content to be streamed.
this . ChatThread = await aiText . CreateFromProviderAsync ( this . ProviderSettings . CreateProvider (), this . ProviderSettings . Model , this . LastUserPrompt , this . ChatThread , this . CancellationTokenSource !. Token );
// Return the AI response:
return aiText . Text ;
}
catch ( ProviderRequestException e )
2025-05-17 19:17:29 +02:00
{
2026-05-25 17:32:54 +02:00
this . Logger . LogError ( e , "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'" , this . Title , e . StatusCode , e . ReasonPhrase , e . ResponseBody );
await MessageBus . INSTANCE . SendError ( new ( Icons . Material . Filled . CloudOff , e . UserMessage ));
2026-07-03 15:02:20 +02:00
if ( this . ResultingContentBlock is not null && string . IsNullOrWhiteSpace ( aiText . Text ))
2026-05-25 17:32:54 +02:00
{
2026-07-03 15:02:20 +02:00
this . ChatThread ?. Blocks . Remove ( this . ResultingContentBlock );
this . ResultingContentBlock = null ;
2026-05-25 17:32:54 +02:00
}
return string . Empty ;
2025-05-17 19:17:29 +02:00
}
2026-05-25 17:32:54 +02:00
finally
{
2026-07-03 15:02:20 +02:00
this . IsProcessing = this . assistantSessionId is not null && ( this . AssistantSessionService . TryGetSnapshot ( this . assistantSessionKey )?. IsActive ?? false );
await this . CheckpointAssistantSession ();
await this . RefreshAssistantUIAsync ();
2025-05-17 19:17:29 +02:00
2026-05-25 17:32:54 +02:00
if ( manageCancellationLocally )
{
this . CancellationTokenSource ?. Dispose ();
this . CancellationTokenSource = null ;
}
2026-08-23 21:15:37 +02:00
//
// The handlers above close over this assistant, and the content stays in the chat
// thread. The stream is over by now, so nothing has to listen to it anymore:
//
aiText . ResetStreamingHandlers ();
2026-05-25 17:32:54 +02:00
}
2024-08-13 08:57:58 +02:00
}
2026-07-03 15:02:20 +02:00
/// <summary>
/// Starts the current assistant chat thread as a regular background-capable chat generation job.
/// </summary>
/// <remarks>
/// Use this when an assistant creates a chat and hands it over to the chat page instead of
/// rendering the answer inside the assistant UI.
/// </remarks>
/// <param name="time">The timestamp to use for the AI response block.</param>
/// <param name="hideContentFromUser">Whether the AI response block should be hidden from the user.</param>
/// <param name="isForeground">Whether the chat job should start as the current foreground job.</param>
/// <returns>A task that completes after the chat job was registered.</returns>
protected async Task StartChatGenerationJobAsync ( DateTimeOffset time , bool hideContentFromUser = false , bool isForeground = true )
{
if ( this . ChatThread is null )
return ;
var aiText = new ContentText
{
InitialRemoteWait = true ,
};
this . ResultingContentBlock = new ContentBlock
{
Time = time ,
ContentType = ContentType . TEXT ,
Role = ChatRole . AI ,
Content = aiText ,
HideFromUser = hideContentFromUser ,
};
this . ChatThread . Blocks . Add ( this . ResultingContentBlock );
this . ChatThread . SelectedProvider = this . ProviderSettings . Id ;
2026-10-04 14:44:16 +02:00
this . AssignRuntimeIdentity ( this . ChatThread );
2026-07-03 15:02:20 +02:00
await this . CheckpointAssistantSession ();
await this . AIJobService . TryStartChatGenerationAsync ( new ChatGenerationRequest
{
ChatThread = this . ChatThread ,
AIText = aiText ,
LastUserPrompt = this . LastUserPrompt ,
ProviderSettings = this . ProviderSettings ,
IsForeground = isForeground ,
});
}
2024-08-13 08:57:58 +02:00
2026-08-11 20:14:21 +02:00
private Task CancelStreaming () => this . CancelAssistantSessionAsync ();
/// <summary>
/// Requests cancellation of the active assistant session.
/// </summary>
/// <remarks>
/// Derived assistants should use this method instead of accessing their local
/// cancellation token source. A component which reattaches after navigation
/// does not own that source, while the session service still does.
/// </remarks>
/// <returns>A task that completes after cancellation was requested.</returns>
protected Task CancelAssistantSessionAsync () => this . AssistantSessionService . CancelAsync ( this . assistantSessionKey , this );
2025-03-17 18:00:26 +01:00
2024-08-23 08:59:30 +02:00
protected async Task CopyToClipboard ()
2024-08-13 20:53:09 +02:00
{
2026-10-16 20:42:04 +02:00
var result = this . Result2Copy ();
if ( this . AddsAIGeneratedContentDisclosure )
result = this . DisclosureService . AddDisclosureToMarkdown ( result );
await this . RustService . CopyText2Clipboard ( result );
2024-08-13 20:53:09 +02:00
}
2026-04-16 16:12:45 +02:00
private ChatThread CreateSendToChatThread ()
{
2026-04-16 16:48:47 +02:00
var originalChatThread = this . ChatThread ?? new ChatThread ();
2026-04-16 16:12:45 +02:00
if ( string . IsNullOrWhiteSpace ( this . SendToChatVisibleUserPromptText ))
return originalChatThread with
{
SystemPrompt = SystemPrompts . DEFAULT ,
};
var transferredBlocks = originalChatThread . Blocks
. Select ( block => block . Role is ChatRole . USER
? block . DeepClone ( changeHideState : true )
: block . DeepClone ())
. ToList ();
2026-10-28 08:51:30 +01:00
transferredBlocks . Insert ( 0 , this . CreateSendToChatPrompt ( originalChatThread . Blocks ));
return originalChatThread with
{
SystemPrompt = SystemPrompts . DEFAULT ,
Blocks = transferredBlocks ,
};
}
/// <summary>
/// Creates the visible message of the user which opens a chat started from this assistant.
/// </summary>
/// <param name="blocks">The blocks which follow the message in the chat; it comes right before the earliest of them.</param>
/// <returns>The message, with SendToChatVisibleUserPromptText as its text.</returns>
private protected ContentBlock CreateSendToChatPrompt ( IReadOnlyCollection < ContentBlock > blocks )
{
var earliestBlock = blocks . MinBy ( x => x . Time );
var visiblePromptTime = earliestBlock is null
? DateTimeOffset . Now
: earliestBlock . Time == DateTimeOffset . MinValue
? earliestBlock . Time
: earliestBlock . Time . AddTicks (- 1 );
return new ContentBlock
2026-04-16 16:12:45 +02:00
{
Time = visiblePromptTime ,
ContentType = ContentType . TEXT ,
HideFromUser = false ,
Role = ChatRole . USER ,
Content = new ContentText
{
2026-10-28 08:51:30 +01:00
Text = this . SendToChatVisibleUserPromptText ?? string . Empty ,
2026-04-16 16:12:45 +02:00
},
};
}
2024-08-13 20:53:09 +02:00
2024-08-13 08:57:58 +02:00
private static string? GetButtonIcon ( string icon )
{
if ( string . IsNullOrWhiteSpace ( icon ))
return null ;
return icon ;
2024-07-14 21:46:17 +02:00
}
2024-08-18 12:32:18 +02:00
2025-03-12 11:31:01 +01:00
protected async Task OpenSettingsDialog ()
{
2026-02-01 14:50:19 +01:00
if (! this . HasSettingsPanel )
return ;
2025-03-12 11:31:01 +01:00
var dialogParameters = new DialogParameters ();
2025-03-16 15:19:17 +01:00
await this . DialogService . ShowAsync < TSettings >( null , dialogParameters , DialogOptions . FULLSCREEN );
2025-03-12 11:31:01 +01:00
}
2026-07-03 15:02:20 +02:00
protected async Task SendToAssistant ( Tools . Components destination , SendToButton sendToButton )
2024-08-18 12:32:18 +02:00
{
2026-10-28 13:28:30 +01:00
if (! this . VisibleSendToAssistants . Contains ( destination ))
2026-07-03 15:02:20 +02:00
return ;
2024-11-02 11:55:09 +01:00
2024-10-28 15:41:00 +01:00
var contentToSend = sendToButton == default ? string . Empty : sendToButton . UseResultingContentBlockData switch
2024-08-18 12:32:18 +02:00
{
2024-08-18 21:48:35 +02:00
false => sendToButton . GetText (),
2026-07-03 15:02:20 +02:00
true => this . ResultingContentBlock ?. Content switch
2024-08-18 12:32:18 +02:00
{
ContentText textBlock => textBlock . Text ,
_ => string . Empty ,
},
};
2026-10-28 13:28:30 +01:00
// 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 )
2026-07-03 15:02:20 +02:00
{
2026-10-28 13:28:30 +01:00
var convertedChatThread = this . ConvertToChatThread ;
convertedChatThread = convertedChatThread with { SelectedProvider = this . ProviderSettings . Id };
2026-07-03 15:02:20 +02:00
2026-10-28 13:28:30 +01:00
//
// 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 );
2026-10-26 10:00:01 +01:00
2026-10-28 13:28:30 +01:00
// 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 );
2026-10-26 10:00:01 +01:00
2026-10-28 13:28:30 +01:00
MessageBus . INSTANCE . DeferMessage ( this , Event . SEND_TO_CHAT , new ChatStartRequest ( convertedChatThread ));
this . NavigationManager . NavigateTo ( Routes . CHAT );
return ;
2024-08-18 21:48:35 +02:00
}
2026-10-28 13:28:30 +01:00
// 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 );
2024-08-18 12:32:18 +02:00
}
2026-03-14 10:37:31 +01:00
2024-08-18 21:48:35 +02:00
private async Task InnerResetForm ()
{
2026-07-15 12:53:30 +02:00
if (( this . AssistantSessionService . TryGetSnapshot ( this . assistantSessionKey )?. IsActive ?? false )
|| this . MediaTranscriptionService . IsBusy ( this . CurrentMediaImportOwner ))
2026-07-03 15:02:20 +02:00
return ;
await this . AssistantSessionService . ClearAsync ( this . assistantSessionKey );
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . ClearOwnerState ( this . CurrentMediaImportOwner );
2026-07-03 15:02:20 +02:00
this . assistantSessionId = null ;
2026-09-22 20:03:08 +02:00
this . ClearConversationState ();
2026-10-28 13:28:30 +01:00
// The text sent here is gone with the form, and what it demanded goes with it:
this . transferredRestrictions = ChatRestrictions . NONE ;
2026-04-16 16:48:47 +02:00
this . ProviderSettings = Settings . Provider . NONE ;
2024-08-18 21:48:35 +02:00
2026-08-06 12:13:08 +02:00
await this . JsRuntime . ClearDiv ( BEFORE_RESULT_DIV_ID );
2024-08-21 08:30:01 +02:00
await this . JsRuntime . ClearDiv ( RESULT_DIV_ID );
2024-08-18 21:48:35 +02:00
await this . JsRuntime . ClearDiv ( AFTER_RESULT_DIV_ID );
2025-01-01 15:49:27 +01:00
this . ResetForm ();
2026-02-01 14:50:19 +01:00
this . ResetProviderAndProfileSelection ();
2026-08-11 20:14:21 +02:00
await this . OnDefaultsAppliedAsync ();
2024-08-18 21:48:35 +02:00
2026-04-16 16:48:47 +02:00
this . InputIsValid = false ;
2026-07-03 15:02:20 +02:00
this . InputIssues = [];
2024-08-18 21:48:35 +02:00
2026-10-31 10:48:20 +01:00
if ( this . Form is not null )
await this . Form . ResetValidationAsync ();
2026-07-03 15:02:20 +02:00
await this . RefreshAssistantUIAsync ();
2026-10-31 10:48:20 +01:00
if ( this . Form is not null )
await this . Form . ResetValidationAsync ();
2024-08-18 21:48:35 +02:00
}
2024-09-15 12:30:07 +02:00
private string GetResetColor () => this . SettingsManager . IsDarkMode switch
{
true => $"background-color: #804000" ,
false => $"background-color: {this.ColorTheme.GetCurrentPalette(this.SettingsManager).Warning.Value}" ,
};
private string GetSendToColor () => this . SettingsManager . IsDarkMode switch
{
true => $"background-color: #004080" ,
false => $"background-color: {this.ColorTheme.GetCurrentPalette(this.SettingsManager).InfoLighten}" ,
};
2025-04-27 09:06:05 +02:00
#region Overrides of MSGComponentBase
2024-09-15 12:30:07 +02:00
2025-04-27 09:06:05 +02:00
protected override void DisposeResources ()
2024-09-15 12:30:07 +02:00
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged -= this . OnMediaImportStateChanged ;
2026-07-03 15:02:20 +02:00
this . isDisposed = true ;
2025-08-15 21:16:51 +02:00
try
{
this . formChangeTimer . Stop ();
this . formChangeTimer . Dispose ();
}
catch
{
// ignore
}
base . DisposeResources ();
2024-09-15 12:30:07 +02:00
}
2026-07-15 12:53:30 +02:00
/// <summary>Refreshes assistant actions when the shared import lane changes.</summary>
private void OnMediaImportStateChanged ( MediaImportOwner owner )
{
if ( owner == this . CurrentMediaImportOwner )
2026-08-25 18:53:09 +02:00
this . InvokeAsync ( async () =>
2026-07-15 12:53:30 +02:00
{
await this . ConsumeMediaOutcomeAsync ();
this . StateHasChanged ();
2026-11-04 14:04:10 +01:00
}). Observe ( $"{nameof(AssistantBase<>)}: consuming a media import outcome" );
2026-07-15 12:53:30 +02:00
}
/// <summary>Consumes a terminal media notification when this assistant is visible.</summary>
private async Task ConsumeMediaOutcomeAsync ()
{
var outcome = this . MediaTranscriptionService . TryConsumeOutcome ( this . CurrentMediaImportOwner );
if ( outcome is null )
return ;
if ( outcome . Failures . Count > 0 )
{
var message = string . Join ( Environment . NewLine , outcome . Failures . Select ( failure => $"{failure.FileName}: {failure.UserMessage}" ));
await this . MessageBus . SendError ( new ( Icons . Material . Filled . VoiceChat , message ));
}
else if ( outcome . Status is MediaImportStatus . FAILED )
{
await this . MessageBus . SendError ( new ( Icons . Material . Filled . VoiceChat , this . TB ( "The media file could not be transcribed." )));
}
if ( outcome . Warnings . Count > 0 )
{
var message = string . Join ( Environment . NewLine , outcome . Warnings . Select ( warning => $"{warning.FileName}: {warning.UserMessage}" ));
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . VoiceChat , message ));
}
if ( outcome . Status is MediaImportStatus . CANCELLED )
{
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . VoiceChat , this . TB ( "The media transcription was canceled." )));
}
}
2024-09-15 12:30:07 +02:00
#endregion
2026-07-03 15:02:20 +02:00
#region Assistant sessions
/// <summary>
/// Stores the current assistant UI and chat state in the active assistant session.
/// </summary>
/// <returns>A task that completes after the checkpoint was stored and published.</returns>
2026-08-11 20:14:21 +02:00
protected Task CheckpointAssistantSession ()
2026-07-03 15:02:20 +02:00
{
if ( this . assistantSessionId is null )
return Task . CompletedTask ;
return this . AssistantSessionService . CheckpointAsync ( this . assistantSessionKey , this . assistantSessionId . Value , this . Title , this . ChatThread , this . CaptureAssistantSessionState (), this );
}
/// <summary>
/// Allows derived assistants to restore client-only UI after a session was attached.
/// </summary>
/// <param name="snapshot">The assistant session snapshot that was attached.</param>
/// <returns>A task that completes after derived UI restore work has finished.</returns>
protected virtual Task OnAssistantSessionAttachedAsync ( AssistantSessionSnapshot snapshot ) => Task . CompletedTask ;
/// <summary>
/// Allows derived assistants to restore DOM-dependent client-only UI after an attached session was rendered.
/// </summary>
/// <param name="snapshot">The assistant session snapshot that was rendered.</param>
/// <returns>A task that completes after derived UI restore work has finished.</returns>
protected virtual Task OnAssistantSessionRenderedAsync ( AssistantSessionSnapshot snapshot ) => Task . CompletedTask ;
/// <summary>
/// Handles assistant session change events for the current assistant instance.
/// </summary>
/// <typeparam name="T">The message payload type.</typeparam>
/// <param name="sendingComponent">The component that sent the message, if any.</param>
/// <param name="triggeredEvent">The event that was triggered.</param>
/// <param name="data">The message payload.</param>
/// <returns>A task that completes after the message was processed.</returns>
protected override async Task ProcessIncomingMessage < T >( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
{
if ( ReferenceEquals ( sendingComponent , this ))
return ;
switch ( triggeredEvent )
{
case Event . ASSISTANT_SESSION_CHANGED :
case Event . ASSISTANT_SESSION_FINISHED :
if ( data is AssistantSessionSnapshot snapshot && snapshot . Key == this . assistantSessionKey )
{
await this . AttachAssistantSession ( snapshot , restoreClientOnlyContent : triggeredEvent is Event . ASSISTANT_SESSION_FINISHED );
if ( triggeredEvent is Event . ASSISTANT_SESSION_FINISHED )
_ = this . AssistantSessionService . TryTakeInactiveSnapshot ( this . assistantSessionKey );
}
break ;
}
}
/// <summary>
/// Attaches the component to an existing assistant session if one is available.
/// </summary>
/// <returns>A task that completes after the session was attached.</returns>
private async Task AttachAssistantSessionIfAvailable ()
{
var snapshot = this . AssistantSessionService . TryGetSnapshot ( this . assistantSessionKey );
if ( snapshot ?. IsActive ?? false )
{
await this . AttachAssistantSession ( snapshot , restoreClientOnlyContent : true );
return ;
}
snapshot = this . AssistantSessionService . TryTakeInactiveSnapshot ( this . assistantSessionKey );
if ( snapshot is null )
return ;
await this . AttachAssistantSession ( snapshot , restoreClientOnlyContent : true );
}
/// <summary>
/// Applies an assistant session snapshot to this component instance.
/// </summary>
2026-10-25 21:09:23 +01:00
/// <remarks>
/// When the user leaves an assistant while it runs and comes back, this component only shows the run.
/// The component which started it keeps working out of sight and reports every few seconds, yet its
/// form stays as it was when the user left. So once this component has taken over a run, it takes over
/// only what the running assistant changed since, such as the progress and the result. What the user
/// changes here stays, just as it would in the component which started the run.
/// </remarks>
2026-07-03 15:02:20 +02:00
/// <param name="snapshot">The snapshot to attach.</param>
/// <param name="restoreClientOnlyContent">Whether derived assistants should restore client-only UI state.</param>
/// <returns>A task that completes after the component was refreshed.</returns>
private async Task AttachAssistantSession ( AssistantSessionSnapshot snapshot , bool restoreClientOnlyContent )
{
this . assistantSessionId = snapshot . SessionId ;
2026-10-25 21:09:23 +01:00
this . ImportAssistantSessionState ( snapshot . StateChangedSince ( this . lastAttachedAssistantSessionSnapshot ));
this . lastAttachedAssistantSessionSnapshot = snapshot ;
2026-07-03 15:02:20 +02:00
this . ChatThread = snapshot . ChatThread ?? this . ChatThread ;
this . IsProcessing = snapshot . IsActive ;
2026-10-25 22:10:38 +01:00
this . processingStartedAt = snapshot . IsActive ? snapshot . StartedAt : null ;
2026-07-03 15:02:20 +02:00
if (! snapshot . IsActive )
this . CancellationTokenSource = null ;
if ( restoreClientOnlyContent )
await this . OnAssistantSessionAttachedAsync ( snapshot );
if ( restoreClientOnlyContent )
this . pendingRenderedAssistantSessionSnapshot = snapshot ;
await this . RefreshAssistantUIAsync ();
}
/// <summary>
/// Refreshes the component when it is still mounted.
/// </summary>
2026-10-25 18:52:39 +01:00
/// <param name="hasChanged">Decides whether anything visible changed, asked on the thread of the renderer, so it may read what the rendering reads; null to render in any case.</param>
2026-07-03 15:02:20 +02:00
/// <returns>A task that completes after the renderer was notified.</returns>
2026-10-25 18:52:39 +01:00
protected async Task RefreshAssistantUIAsync ( Func < bool >? hasChanged = null )
2026-07-03 15:02:20 +02:00
{
if ( this . isDisposed )
return ;
try
{
2026-10-25 18:52:39 +01:00
await this . InvokeAsync (() =>
{
if ( hasChanged is null || hasChanged ())
this . StateHasChanged ();
});
2026-07-03 15:02:20 +02:00
}
catch ( InvalidOperationException )
{
// The component may already have left the renderer while a background session is finishing.
}
}
/// <summary>
/// Captures the base assistant state and assistant-specific typed state values for session restore.
/// </summary>
/// <returns>A dictionary containing the current assistant state.</returns>
private Dictionary < string , IAssistantSessionSnapshotField > CaptureAssistantSessionState ()
{
var state = new AssistantSessionStateWriter ();
state . Set ( PROVIDER_SETTINGS_STATE_KEY , this . ProviderSettings );
state . Set ( INPUT_IS_VALID_STATE_KEY , this . InputIsValid );
state . Set ( CURRENT_PROFILE_STATE_KEY , this . CurrentProfile );
state . Set ( CURRENT_CHAT_TEMPLATE_STATE_KEY , this . CurrentChatTemplate );
state . Set ( CHAT_THREAD_STATE_KEY , this . ChatThread );
state . Set ( LAST_USER_PROMPT_STATE_KEY , this . LastUserPrompt );
state . Set ( RESULTING_CONTENT_BLOCK_STATE_KEY , this . ResultingContentBlock );
state . Set ( INPUT_ISSUES_STATE_KEY , this . InputIssues );
state . Set ( IS_PROCESSING_STATE_KEY , this . IsProcessing );
2026-09-04 15:48:07 +02:00
state . Set ( SELECTED_TOOL_IDS_STATE_KEY , this . SelectedToolIds );
2026-10-28 13:28:30 +01:00
state . Set ( TRANSFERRED_RESTRICTIONS_STATE_KEY , this . transferredRestrictions );
2026-07-03 15:02:20 +02:00
this . CaptureCustomAssistantSessionState ( state );
return state . ToDictionary ();
}
/// <summary>
/// Captures assistant-specific state values.
/// </summary>
/// <param name="state">The typed state writer to update.</param>
protected virtual void CaptureCustomAssistantSessionState ( AssistantSessionStateWriter state ) { }
/// <summary>
/// Restores the base assistant state and assistant-specific typed state values from a session snapshot.
/// </summary>
/// <param name="state">The captured assistant state to import.</param>
private void ImportAssistantSessionState ( IReadOnlyDictionary < string , IAssistantSessionSnapshotField > state )
{
var reader = new AssistantSessionStateReader ( state , this . Title );
reader . Restore ( PROVIDER_SETTINGS_STATE_KEY , value => this . ProviderSettings = value );
reader . Restore ( INPUT_IS_VALID_STATE_KEY , value => this . InputIsValid = value );
reader . Restore ( CURRENT_PROFILE_STATE_KEY , value => this . CurrentProfile = value );
reader . Restore ( CURRENT_CHAT_TEMPLATE_STATE_KEY , value => this . CurrentChatTemplate = value );
reader . Restore ( CHAT_THREAD_STATE_KEY , value => this . ChatThread = value );
reader . Restore ( LAST_USER_PROMPT_STATE_KEY , value => this . LastUserPrompt = value );
reader . Restore ( RESULTING_CONTENT_BLOCK_STATE_KEY , value => this . ResultingContentBlock = value );
reader . Restore ( INPUT_ISSUES_STATE_KEY , value => this . InputIssues = value );
reader . Restore ( IS_PROCESSING_STATE_KEY , value => this . IsProcessing = value );
2026-10-04 12:26:26 +02:00
reader . Restore ( SELECTED_TOOL_IDS_STATE_KEY , value => this . SelectedToolIds = this . ToolRegistry . NormalizeSelection ( value ));
2026-10-28 13:28:30 +01:00
reader . Restore ( TRANSFERRED_RESTRICTIONS_STATE_KEY , value => this . transferredRestrictions = value );
2026-07-03 15:02:20 +02:00
this . RestoreCustomAssistantSessionState ( reader );
}
/// <summary>
/// Restores assistant-specific state values.
/// </summary>
/// <param name="state">The typed state reader to read from.</param>
protected virtual void RestoreCustomAssistantSessionState ( AssistantSessionStateReader state ) { }
#endregion
2026-09-04 15:48:07 +02:00
}