2026-09-13 14:17:25 +02:00
using System.Globalization ;
2025-01-02 13:16:47 +01:00
using AIStudio.Chat ;
using AIStudio.Dialogs ;
using AIStudio.Provider ;
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
2026-09-04 15:48:07 +02:00
using AIStudio.Tools.ToolCallingSystem ;
2026-05-24 13:50:42 +02:00
using AIStudio.Tools.AIJobs ;
2026-07-15 12:53:30 +02:00
using AIStudio.Tools.Media ;
using AIStudio.Tools.Services ;
2025-01-02 13:16:47 +01:00
using Microsoft.AspNetCore.Components ;
using Microsoft.AspNetCore.Components.Web ;
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
namespace AIStudio.Components ;
2026-08-23 21:15:37 +02:00
public partial class ChatComponent : MSGComponentBase
2025-01-02 13:16:47 +01:00
{
2026-07-15 12:53:30 +02:00
private readonly Guid draftMediaOwnerId = Guid . NewGuid ();
2026-03-14 12:40:40 +01:00
private const string CHAT_INPUT_ID = "chat-user-input" ;
private const string MARKDOWN_CODE = "code" ;
private const string MARKDOWN_BOLD = "bold" ;
private const string MARKDOWN_ITALIC = "italic" ;
private const string MARKDOWN_HEADING = "heading" ;
private const string MARKDOWN_BULLET_LIST = "bullet_list" ;
2025-01-02 13:16:47 +01:00
[Parameter]
public ChatThread ? ChatThread { get ; set ; }
[Parameter]
public EventCallback < ChatThread ?> ChatThreadChanged { get ; set ; }
2025-08-26 10:59:56 +02:00
2025-01-02 13:16:47 +01:00
[Parameter]
2025-08-26 10:59:56 +02:00
public AIStudio . Settings . Provider Provider { get ; set ; } = AIStudio . Settings . Provider . NONE ;
2025-01-02 13:16:47 +01:00
[Parameter]
2025-01-05 15:11:15 +01:00
public EventCallback < AIStudio . Settings . Provider > ProviderChanged { get ; set ; }
2025-01-02 13:16:47 +01:00
[Parameter]
public Action < string > WorkspaceName { get ; set ; } = _ => { };
[Parameter]
public Workspaces ? Workspaces { get ; set ; }
2026-05-25 20:48:26 +02:00
[Parameter]
public ChatComposerState ComposerState { get ; set ; } = new ();
2025-01-02 13:16:47 +01:00
[Inject]
private ILogger < ChatComponent > Logger { get ; set ; } = null !;
2025-12-10 13:48:13 +01:00
2026-09-04 15:48:07 +02:00
[Inject]
private ToolRegistry ToolRegistry { get ; set ; } = null !;
2025-01-02 13:16:47 +01:00
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
2026-09-13 14:17:25 +02:00
[Inject]
private ConversationTokenCounter ConversationTokenCounter { get ; init ; } = null !;
2026-03-14 12:40:40 +01:00
[Inject]
private IJSRuntime JsRuntime { get ; init ; } = null !;
2025-12-10 13:48:13 +01:00
2026-05-24 13:50:42 +02:00
[Inject]
private AIJobService AIJobService { get ; init ; } = null !;
2026-07-15 12:53:30 +02:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null !;
2025-01-21 19:22:26 +01:00
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement . Top ;
2025-01-02 13:16:47 +01:00
private static readonly Dictionary < string , object? > USER_INPUT_ATTRIBUTES = new ();
2025-02-15 15:41:12 +01:00
private DataSourceSelection ? dataSourceSelectionComponent ;
private DataSourceOptions earlyDataSourceOptions = new ();
2026-09-22 16:39:17 +02:00
private DataSourceOptions lastAppliedAutomaticDataSourceOptions = new ();
2025-01-02 13:16:47 +01:00
private Profile currentProfile = Profile . NO_PROFILE ;
2025-05-24 19:11:28 +02:00
private ChatTemplate currentChatTemplate = ChatTemplate . NO_CHAT_TEMPLATE ;
2025-01-02 13:16:47 +01:00
private bool hasUnsavedChanges ;
private bool mustScrollToBottomAfterRender ;
private InnerScrolling scrollingArea = null !;
private byte scrollRenderCountdown ;
private bool mustStoreChat ;
private bool mustLoadChat ;
private LoadChat loadChat ;
private bool autoSaveEnabled ;
2026-09-04 15:48:07 +02:00
private HashSet < string > selectedToolIds = [];
2026-06-23 09:02:40 +02:00
private bool previousInputForbidden = true ;
private Guid lastSeenChatId = Guid . Empty ;
private AIStudio . Settings . Provider lastSeenProvider = AIStudio . Settings . Provider . NONE ;
2025-01-02 13:16:47 +01:00
private string currentWorkspaceName = string . Empty ;
private Guid currentWorkspaceId = Guid . Empty ;
2026-03-05 18:37:18 +01:00
private Guid currentChatThreadId = Guid . Empty ;
2026-05-25 20:48:26 +02:00
private Guid loadedParameterChatId = Guid . Empty ;
private Guid loadedParameterWorkspaceId = Guid . Empty ;
2026-05-24 13:50:42 +02:00
private Guid foregroundChatId = Guid . Empty ;
2026-04-15 09:44:12 +02:00
private int workspaceHeaderSyncVersion ;
2026-09-13 14:17:25 +02:00
private ConversationTokens conversationTokens = ConversationTokens . UNAVAILABLE ;
/// <summary>
/// How much of the window must be used before the number starts saying so.
/// </summary>
private const double WINDOW_NEARLY_FULL = 0.8d ;
/// <summary>
/// How long the token count stays quiet after it ran, while nothing is being written.
/// </summary>
/// <remarks>
/// A render is cheap to ask about and a count is not. This is what keeps a burst of renders --
/// loading a chat touches several things in a row -- from turning into a burst of counting,
/// while staying short enough that switching a profile moves the number right away.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_QUIET_TIME = TimeSpan . FromMilliseconds ( 500 );
/// <summary>
/// How long the token count stays quiet while an answer is being written.
/// </summary>
/// <remarks>
/// An answer grows with every word, so each count finds a new number, shows it, and thereby
/// renders -- which asks for the next count. That makes this the whole cadence while a model
/// writes, and three seconds is the pace the chat itself keeps: the job service hands its
/// progress to the screen no more often than that.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_STREAMING_QUIET_TIME = TimeSpan . FromSeconds ( 3 );
/// <summary>
/// How long the token count waits for a reason before counting anyway.
/// </summary>
/// <remarks>
/// For what happens outside AI Studio: an attached document is read from disk every time it is
/// sent, so somebody editing it in another program changes what the next message costs without
/// anything here rendering.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_HEARTBEAT = TimeSpan . FromSeconds ( 10 );
/// <summary>
/// Recomputes the token count whenever something might have changed.
/// </summary>
private ConversationTokenTracker ? tokenTracker ;
/// <summary>
/// How long to leave the token count alone after it ran.
/// </summary>
private TimeSpan TokenCountQuietTime () => this . IsCurrentChatStreaming ? TOKEN_COUNT_STREAMING_QUIET_TIME : TOKEN_COUNT_QUIET_TIME ;
/// <summary>
/// The culture the token numbers are written in.
/// </summary>
/// <remarks>
/// Taken from the language plugin the user chose, not from the machine. AI Studio's language is
/// a setting of its own, and a German who set German would otherwise read English separators
/// inside a German sentence -- where "1,234" means something a thousand times smaller.
/// </remarks>
private CultureInfo currentCulture = CultureInfo . InvariantCulture ;
/// <summary>
/// What the helper text under the input field says about the token budget.
/// </summary>
/// <remarks>
/// Four sentences rather than one built from pieces, because a translator needs to see the
/// whole thing: which of the two numbers is the limit, and where the word for "about" belongs,
/// are decisions no language makes the same way.
///
/// The images are named rather than counted. Every vendor charges a picture differently, and
/// none of those rules can be applied without decoding the file, so the honest answer is to say
/// how many of them the number does not include.
/// </remarks>
private string TokenCountMessage
{
get
{
if (! this . conversationTokens . IsKnown )
return string . Empty ;
var used = TokenAmount . Format ( this . conversationTokens . Tokens , this . currentCulture );
var budget = this . conversationTokens . Window . IsKnown
? string . Format ( this . conversationTokens . IsEstimate ? this . T ( "approx. {0} of {1} tokens" ) : this . T ( "{0} of {1} tokens" ), used , TokenAmount . Format ( this . conversationTokens . Window . DefaultTokens , this . currentCulture ))
: string . Format ( this . conversationTokens . IsEstimate ? this . T ( "approx. {0} tokens" ) : this . T ( "{0} tokens" ), used );
if ( this . conversationTokens . UncountedImages is 0 )
return budget ;
//
// The pictures of the whole conversation, not of the message being written: every one
// of them is sent again with every further message, so a chat runs past the model's
// limit long after anybody last thought about images.
//
var images = this . conversationTokens . TooManyImages
? string . Format ( this . T ( "plus {0} image(s), which is more than the {1} this model accepts" ), this . conversationTokens . UncountedImages , this . conversationTokens . ImageLimits . MaxInOneMessage )
: string . Format ( this . T ( "plus {0} image(s), which cannot be counted" ), this . conversationTokens . UncountedImages );
return $"{budget} {images}" ;
}
}
/// <summary>
/// Takes over the culture of the language the user chose for AI Studio.
/// </summary>
private async Task RefreshCulture ()
{
var activeLanguagePlugin = await this . SettingsManager . GetActiveLanguagePlugin ();
this . currentCulture = CommonTools . DeriveActiveCultureOrInvariant ( activeLanguagePlugin . IETFTag );
}
2025-05-24 12:27:00 +02:00
2026-07-15 12:53:30 +02:00
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner . ForChat ( this . ChatThread ?. ChatId ?? this . draftMediaOwnerId );
2025-01-02 13:16:47 +01:00
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
2026-09-09 18:43:37 +02:00
private UserPromptComponent < string > inputField = null !;
2025-01-02 13:16:47 +01:00
2026-05-25 20:48:26 +02:00
/// <summary>
/// Represents the user's input in the chat interface.
/// </summary>
/// <remarks>
/// This property serves as a bridge between the chat component and the
/// underlying composer state, allowing user input to be dynamically updated
/// and managed. The setter also triggers state changes within the composer
/// to track whether the user has drafted any input.
/// </remarks>
private string UserInput
{
get => this . ComposerState . UserInput ;
set => this . ComposerState . SetUserInput ( value );
}
2025-01-02 13:16:47 +01:00
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ()
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged += this . OnMediaImportStateChanged ;
2026-09-13 14:17:25 +02:00
await this . RefreshCulture ();
//
// The number under the input field follows from the conversation, and nothing in a
// conversation announces that it changed: blocks, attachments and the answer being written
// are plain objects somebody mutates. So it is recomputed rather than notified.
//
this . tokenTracker = new ( this . RecountTokensAsync , this . TokenCountQuietTime , TOKEN_COUNT_HEARTBEAT );
this . tokenTracker . Start ();
2026-09-04 15:48:07 +02:00
2025-02-15 15:41:12 +01:00
// Apply the filters for the message bus:
2026-06-10 21:01:27 +02:00
this . ApplyFilters ([], [ Event . HAS_CHAT_UNSAVED_CHANGES , Event . RESET_CHAT_STATE , Event . CHAT_STREAMING_DONE , Event . AI_JOB_CHANGED , Event . AI_JOB_FINISHED , Event . CHAT_GENERATION_CHANGED , Event . WORKSPACE_RENAMED , Event . CONFIGURATION_CHANGED ]);
2025-01-02 13:16:47 +01:00
// Configure the spellchecking for the user input:
this . SettingsManager . InjectSpellchecking ( USER_INPUT_ATTRIBUTES );
2026-03-14 12:40:40 +01:00
USER_INPUT_ATTRIBUTES [ "id" ] = CHAT_INPUT_ID ;
2025-02-15 15:41:12 +01:00
// Get the preselected profile:
2025-01-02 13:16:47 +01:00
this . currentProfile = this . SettingsManager . GetPreselectedProfile ( Tools . Components . CHAT );
2025-02-15 15:41:12 +01:00
2025-05-24 12:27:00 +02:00
// Get the preselected chat template:
this . currentChatTemplate = this . SettingsManager . GetPreselectedChatTemplate ( Tools . Components . CHAT );
2026-05-25 20:48:26 +02:00
if (! this . ComposerState . HasUserDraft && ! this . ComposerState . HasComposerContent )
this . ComposerState . ApplyTemplate ( this . currentChatTemplate );
2026-01-01 16:47:15 +01:00
2026-09-22 16:39:17 +02:00
await this . ApplyChatTemplateToolSelectionAsync ();
this . lastAppliedAutomaticDataSourceOptions = this . GetAutomaticDataSourceOptions ();
2026-07-05 14:46:31 +02:00
2026-08-23 21:15:37 +02:00
var deferredInput = MessageBus . INSTANCE . TakeDeferredMessages < string >( Event . SEND_TO_CHAT_INPUT ). LastOrDefault ();
2026-04-15 19:40:53 +02:00
if (! string . IsNullOrWhiteSpace ( deferredInput ))
2026-05-25 20:48:26 +02:00
this . ComposerState . SetUserInput ( deferredInput );
2026-01-01 16:47:15 +01:00
2025-02-15 15:41:12 +01:00
//
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
//
2026-08-27 16:19:35 +02:00
var deferredRequest = MessageBus . INSTANCE . TakeDeferredMessages < ChatStartRequest >( Event . SEND_TO_CHAT ). LastOrDefault ();
if ( deferredRequest is not null )
2025-01-02 13:16:47 +01:00
{
2025-02-15 15:41:12 +01:00
//
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
2026-08-27 16:19:35 +02:00
this . ChatThread = deferredRequest . ChatThread ;
2026-01-18 20:36:04 +01:00
this . ChatThread . IncludeDateTime = true ;
2026-09-04 15:48:07 +02:00
this . ApplyToolSelectionOfLoadedChat ();
2026-08-27 16:19:35 +02:00
//
// Apply the chat template of the incoming chat to the composer. Like everywhere else,
// a draft the user typed themselves wins: we must not discard it just because someone
// started a preconfigured chat in the meantime.
//
if ( deferredRequest . ApplySelectedChatTemplateToComposer && ! this . ComposerState . HasUserDraft )
this . ComposerState . ApplyTemplate ( this . SettingsManager . GetChatTemplateById ( this . ChatThread . SelectedChatTemplate ));
2026-01-18 20:36:04 +01:00
2026-01-18 19:52:19 +01:00
this . Logger . LogInformation ( $"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now." );
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2025-01-02 13:16:47 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
2025-02-15 15:41:12 +01:00
// We know already that the chat thread is not null,
// but we have to check it again for the nullability
// for the compiler:
2025-01-02 13:16:47 +01:00
if ( this . ChatThread is not null )
{
2025-02-15 15:41:12 +01:00
//
// Check if the chat thread has a name. If not, we
// generate the name now:
//
2025-01-02 13:16:47 +01:00
if ( string . IsNullOrWhiteSpace ( this . ChatThread . Name ))
{
var firstUserBlock = this . ChatThread . Blocks . FirstOrDefault ( x => x . Role == ChatRole . USER );
if ( firstUserBlock is not null )
{
this . ChatThread . Name = firstUserBlock . Content switch
{
ContentText textBlock => this . ExtractThreadName ( textBlock . Text ),
_ => "Thread"
};
}
}
2025-02-15 15:41:12 +01:00
//
// Check if the user wants to apply the standard chat data source options:
//
2026-08-27 16:19:35 +02:00
if (! deferredRequest . PreserveDataSourceOptions &&
this . SettingsManager . ConfigurationData . Chat . SendToChatDataSourceBehavior is SendToChatDataSourceBehavior . APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS )
2025-02-15 15:41:12 +01:00
this . ChatThread . DataSourceOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ();
//
// Check if the user wants to store the chat automatically:
//
2025-01-02 13:16:47 +01:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
this . autoSaveEnabled = true ;
this . mustStoreChat = true ;
2025-02-15 15:41:12 +01:00
//
// When a standard workspace is used, we have to ensure
// that the workspace is available:
//
2025-01-02 13:16:47 +01:00
if ( this . ChatThread . WorkspaceId == KnownWorkspaces . ERI_SERVER_WORKSPACE_ID )
await WorkspaceBehaviour . EnsureERIServerWorkspace ();
else if ( this . ChatThread . WorkspaceId == KnownWorkspaces . BIAS_WORKSPACE_ID )
await WorkspaceBehaviour . EnsureBiasWorkspace ();
}
}
2025-01-04 18:27:23 +01:00
}
2025-02-15 15:41:12 +01:00
else
{
//
// No, the user did not send an assistant result to the chat.
//
2026-09-22 16:39:17 +02:00
this . ApplyAutomaticDataSourceOptions ();
2025-02-15 15:41:12 +01:00
}
2025-01-04 18:27:23 +01:00
2025-02-15 15:41:12 +01:00
//
// Check if the user wants to show the latest message after loading:
//
2025-01-04 18:27:23 +01:00
if ( this . SettingsManager . ConfigurationData . Chat . ShowLatestMessageAfterLoading )
{
2025-02-15 15:41:12 +01:00
//
// We cannot scroll to the bottom right now because the
// chat component is not rendered yet. We have to wait for
// the rendering process to finish. Thus, we set a flag
// to scroll to the bottom after the rendering process.:
//
2025-01-04 18:27:23 +01:00
this . mustScrollToBottomAfterRender = true ;
this . scrollRenderCountdown = 4 ;
this . StateHasChanged ();
2025-01-02 13:16:47 +01:00
}
2025-02-15 15:41:12 +01:00
//
// Check if another component deferred the loading of a chat.
//
// This is used, e.g., for the bias-of-the-day component:
// when the bias for this day was already produced, the bias
// component sends a message to the chat component to load
// the chat with the bias:
//
2026-08-23 21:15:37 +02:00
var deferredLoading = MessageBus . INSTANCE . TakeDeferredMessages < LoadChat >( Event . LOAD_CHAT ). LastOrDefault ();
2025-01-02 13:16:47 +01:00
if ( deferredLoading != default )
{
this . loadChat = deferredLoading ;
this . mustLoadChat = true ;
2025-01-04 19:27:45 +01:00
this . Logger . LogInformation ( $"The loading of the chat '{this.loadChat.ChatId}' was deferred and will be loaded now." );
2025-01-02 13:16:47 +01:00
}
2025-01-21 19:12:36 +01:00
2025-02-15 15:41:12 +01:00
//
// When for whatever reason we have a chat thread, we have to
// ensure that the corresponding workspace id is set and the
// workspace name is loaded:
//
2025-01-21 19:12:36 +01:00
if ( this . ChatThread is not null )
2026-04-15 09:44:12 +02:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
2025-01-02 13:16:47 +01:00
2025-02-15 15:41:12 +01:00
// Select the correct provider:
2025-01-02 13:16:47 +01:00
await this . SelectProviderWhenLoadingChat ();
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2026-07-15 12:53:30 +02:00
await this . ConsumeMediaOutcomeAsync ();
2025-01-02 13:16:47 +01:00
await base . OnInitializedAsync ();
}
2026-07-15 12:53:30 +02:00
/// <summary>Refreshes send and attachment controls when the media 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-08-25 18:53:09 +02:00
}). Observe ( $"{nameof(ChatComponent)}: consuming a media import outcome" );
2026-07-15 12:53:30 +02:00
}
/// <summary>Consumes a terminal media notification when its chat 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 . T ( "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 . T ( "The media transcription was canceled." )));
}
}
2025-01-02 13:16:47 +01:00
protected override async Task OnAfterRenderAsync ( bool firstRender )
{
if ( firstRender && this . ChatThread is not null && this . mustStoreChat )
{
this . mustStoreChat = false ;
if ( this . Workspaces is not null )
2026-03-05 18:37:18 +01:00
await this . Workspaces . StoreChatAsync ( this . ChatThread );
2025-01-02 13:16:47 +01:00
else
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . StoreChatAsync ( this . ChatThread );
2026-04-15 09:44:12 +02:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
2025-01-02 13:16:47 +01:00
}
if ( firstRender && this . mustLoadChat )
{
2025-01-04 19:27:45 +01:00
this . Logger . LogInformation ( $"Try to load the chat '{this.loadChat.ChatId}' now." );
2025-01-02 13:16:47 +01:00
this . mustLoadChat = false ;
2026-03-05 18:37:18 +01:00
this . ChatThread = await WorkspaceBehaviour . LoadChatAsync ( this . loadChat );
2025-01-02 13:16:47 +01:00
if ( this . ChatThread is not null )
{
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2025-01-04 19:27:45 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
this . Logger . LogInformation ( $"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully." );
2026-04-15 09:44:12 +02:00
2026-09-04 15:48:07 +02:00
this . ApplyToolSelectionOfLoadedChat ();
2026-04-15 09:44:12 +02:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
2025-01-02 13:16:47 +01:00
await this . SelectProviderWhenLoadingChat ();
}
2025-01-04 19:27:45 +01:00
else
this . Logger . LogWarning ( $"The chat '{this.loadChat.ChatId}' could not be loaded." );
2025-01-02 13:16:47 +01:00
this . StateHasChanged ();
}
if ( this . mustScrollToBottomAfterRender )
{
if (-- this . scrollRenderCountdown == 0 )
{
await this . scrollingArea . ScrollToBottom ();
this . mustScrollToBottomAfterRender = false ;
}
else
{
this . StateHasChanged ();
}
}
2026-06-23 09:02:40 +02:00
var inputForbidden = this . IsInputForbidden ();
if (! inputForbidden && this . previousInputForbidden )
await this . inputField . FocusAsync ();
this . previousInputForbidden = inputForbidden ;
2026-09-13 14:17:25 +02:00
//
// Everything which can move the token count also renders this component: the selections in
// the toolbar, the attachments and the composer all travel through an event callback whose
// receiver is this component, and the streamed answer arrives as a message which already
// asks for a render. So this one line stands in for the fifteen call sites which used to be
// spread over this file -- and which kept missing one.
//
this . tokenTracker ?. Nudge ();
2025-01-02 13:16:47 +01:00
await base . OnAfterRenderAsync ( firstRender );
}
2026-03-05 18:37:18 +01:00
protected override async Task OnParametersSetAsync ()
{
2026-06-23 09:02:40 +02:00
var incomingChatId = this . ChatThread ?. ChatId ?? Guid . Empty ;
if ( incomingChatId != this . lastSeenChatId || this . Provider != this . lastSeenProvider )
{
this . lastSeenChatId = incomingChatId ;
this . lastSeenProvider = this . Provider ;
this . previousInputForbidden = true ;
}
2026-05-25 20:48:26 +02:00
await this . ApplyLoadedChatParameterAsync ();
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2026-07-15 12:53:30 +02:00
await this . ConsumeMediaOutcomeAsync ();
2026-03-05 18:37:18 +01:00
await base . OnParametersSetAsync ();
}
2025-01-02 13:16:47 +01:00
#endregion
2026-03-05 18:37:18 +01:00
2026-05-25 20:48:26 +02:00
private async Task ApplyLoadedChatParameterAsync ()
{
var chatId = this . ChatThread ?. ChatId ?? Guid . Empty ;
var workspaceId = this . ChatThread ?. WorkspaceId ?? Guid . Empty ;
if ( this . loadedParameterChatId == chatId && this . loadedParameterWorkspaceId == workspaceId )
{
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
return ;
}
this . loadedParameterChatId = chatId ;
this . loadedParameterWorkspaceId = workspaceId ;
await this . LoadedChatChanged ( notifyParent : false );
}
private void MarkCurrentChatAsLoadedParameter ()
{
this . loadedParameterChatId = this . ChatThread ?. ChatId ?? Guid . Empty ;
this . loadedParameterWorkspaceId = this . ChatThread ?. WorkspaceId ?? Guid . Empty ;
}
2026-03-05 18:37:18 +01:00
private async Task SyncWorkspaceHeaderWithChatThreadAsync ()
{
2026-04-15 09:44:12 +02:00
var syncVersion = Interlocked . Increment ( ref this . workspaceHeaderSyncVersion );
var currentChatThread = this . ChatThread ;
if ( currentChatThread is null )
2026-03-05 18:37:18 +01:00
{
2026-04-15 09:44:12 +02:00
this . ClearWorkspaceHeaderState ();
2026-03-05 18:37:18 +01:00
return ;
}
// Guard: If ChatThread ID and WorkspaceId haven't changed, skip entirely.
// Using ID-based comparison instead of name-based to correctly handle
// temporary chats where the workspace name is always empty.
2026-04-15 09:44:12 +02:00
if ( this . currentChatThreadId == currentChatThread . ChatId
&& this . currentWorkspaceId == currentChatThread . WorkspaceId )
2026-03-05 18:37:18 +01:00
return ;
2026-04-15 09:44:12 +02:00
var chatThreadId = currentChatThread . ChatId ;
var workspaceId = currentChatThread . WorkspaceId ;
var loadedWorkspaceName = await WorkspaceBehaviour . LoadWorkspaceNameAsync ( workspaceId );
// A newer sync request was started while awaiting IO. Ignore stale results.
if ( syncVersion != this . workspaceHeaderSyncVersion )
return ;
// The active chat changed while loading the workspace name.
if ( this . ChatThread is null
|| this . ChatThread . ChatId != chatThreadId
|| this . ChatThread . WorkspaceId != workspaceId )
return ;
this . currentChatThreadId = chatThreadId ;
this . currentWorkspaceId = workspaceId ;
this . PublishWorkspaceNameIfChanged ( loadedWorkspaceName );
}
private void ClearWorkspaceHeaderState ()
{
this . currentChatThreadId = Guid . Empty ;
this . currentWorkspaceId = Guid . Empty ;
this . PublishWorkspaceNameIfChanged ( string . Empty );
}
2026-03-05 18:37:18 +01:00
2026-04-15 09:44:12 +02:00
private void PublishWorkspaceNameIfChanged ( string workspaceName )
{
2026-03-05 18:37:18 +01:00
// Only notify the parent when the name actually changed to prevent
2026-04-15 09:44:12 +02:00
// an infinite render loop: WorkspaceName -> UpdateWorkspaceName ->
// StateHasChanged -> re-render -> OnParametersSetAsync -> WorkspaceName -> ...
if ( this . currentWorkspaceName == workspaceName )
return ;
this . currentWorkspaceName = workspaceName ;
this . WorkspaceName ( this . currentWorkspaceName );
2026-03-05 18:37:18 +01:00
}
2026-04-15 09:44:12 +02:00
2026-06-06 10:06:41 +02:00
private async Task RefreshRenamedWorkspaceHeaderAsync ( Guid workspaceId )
{
var currentChatThread = this . ChatThread ;
if ( currentChatThread is null || currentChatThread . WorkspaceId != workspaceId )
return ;
var syncVersion = Interlocked . Increment ( ref this . workspaceHeaderSyncVersion );
var chatThreadId = currentChatThread . ChatId ;
var loadedWorkspaceName = await WorkspaceBehaviour . LoadWorkspaceNameAsync ( workspaceId );
if ( syncVersion != this . workspaceHeaderSyncVersion )
return ;
if ( this . ChatThread is null
|| this . ChatThread . ChatId != chatThreadId
|| this . ChatThread . WorkspaceId != workspaceId )
return ;
this . currentChatThreadId = chatThreadId ;
this . currentWorkspaceId = workspaceId ;
this . PublishWorkspaceNameIfChanged ( loadedWorkspaceName );
}
2026-05-24 13:50:42 +02:00
private async Task SyncForegroundChatAsync ()
{
var nextForegroundChatId = this . ChatThread ?. ChatId ?? Guid . Empty ;
if ( this . foregroundChatId == nextForegroundChatId )
return ;
if ( this . foregroundChatId != Guid . Empty )
await this . AIJobService . SetForegroundAsync ( AIJobKind . CHAT_GENERATION , this . foregroundChatId , false );
this . foregroundChatId = nextForegroundChatId ;
if ( this . foregroundChatId != Guid . Empty )
await this . AIJobService . SetForegroundAsync ( AIJobKind . CHAT_GENERATION , this . foregroundChatId , true );
}
2025-01-02 13:16:47 +01:00
private bool IsProviderSelected => this . Provider . UsedLLMProvider != LLMProviders . NONE ;
2026-05-24 13:50:42 +02:00
private bool IsCurrentChatStreaming => this . ChatThread is not null && this . AIJobService . IsChatGenerationActive ( this . ChatThread . ChatId );
2025-01-02 13:16:47 +01:00
2025-04-27 09:06:05 +02:00
private string ProviderPlaceholder => this . IsProviderSelected ? T ( "Type your input here..." ) : T ( "Select a provider first" );
private string InputLabel
{
get
{
if ( this . IsProviderSelected )
return string . Format ( T ( "Your Prompt (use selected instance '{0}', provider '{1}')" ), this . Provider . InstanceName , this . Provider . UsedLLMProvider . ToName ());
return this . T ( "Select a provider first" );
}
}
2025-01-02 13:16:47 +01:00
2025-07-11 09:57:46 +02:00
private bool CanThreadBeSaved => this . ChatThread is not null && this . ChatThread . Blocks . Any ( b => ! b . HideFromUser );
2025-01-02 13:16:47 +01:00
2025-05-17 14:22:10 +02:00
private string TooltipAddChatToWorkspace => string . Format ( T ( "Start new chat in workspace '{0}'" ), this . currentWorkspaceName );
2025-01-02 13:16:47 +01:00
2026-06-21 11:52:02 +02:00
private string UserInputStyle => this . SettingsManager . ConfigurationData . Confidence . ShowProviderConfidence ? this . Provider . UsedLLMProvider . GetConfidence ( this . SettingsManager ). SetColorStyle ( this . SettingsManager ) : string . Empty ;
2026-09-13 14:17:25 +02:00
private string UserInputClass => $"{(this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? " confidence - border " : string.Empty)} {this.TokenBudgetClass}" . Trim ();
/// <summary>
/// How much of the model's context window the conversation already takes.
/// </summary>
/// <remarks>
/// Zero whenever nobody wrote the window down. There is then nothing to be full of, and a share
/// of an unknown total would be a number made up on the spot.
/// </remarks>
private double TokenBudgetFill => this . conversationTokens is { IsKnown : true , Window . IsKnown : true }
? ( double ) this . conversationTokens . Tokens / this . conversationTokens . Window . DefaultTokens
: 0d ;
/// <summary>
/// What the number under the input field is coloured with, if anything.
/// </summary>
/// <remarks>
/// Two steps rather than a gradient: below four fifths there is nothing to do about it, above
/// it there is -- shorten the chat, start a new one, or pick a model which reads more -- and
/// past the window the request will be refused or trimmed by the provider.
///
/// Images share the second step and have no first one. There is no "nearly too many pictures":
/// either they fit or the request comes back as an error, and no number of them is worth a
/// warning as long as it fits.
/// </remarks>
private string TokenBudgetClass
{
get
{
//
// Too many pictures is the same kind of news as a full window: the request will be
// refused, and for the same reason -- more was put in than the model takes.
//
if ( this . conversationTokens . TooManyImages || this . TokenBudgetFill >= 1d )
return "token-budget-exceeded" ;
return this . TokenBudgetFill >= WINDOW_NEARLY_FULL ? "token-budget-nearly-full" : string . Empty ;
}
}
2025-01-02 13:16:47 +01:00
2026-09-22 16:39:17 +02:00
/// <summary>
/// Picks the tools a chat starts with: those of its chat template, or the chat defaults.
/// </summary>
/// <remarks>
/// A preselection, not a limit — the user changes it in the chat as usual. A template without a
/// tool selection says nothing about tools and therefore leaves the chat default in place,
/// which is a different statement from a template that selects no tool at all.
/// </remarks>
private async Task ApplyChatTemplateToolSelectionAsync ()
{
if ( this . currentChatTemplate . ToolIds is not { } templateToolIds )
{
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ));
return ;
}
//
// Only the tools the user could have switched on themselves: a template may name one whose
// settings are incomplete — an unconfigured web search, say — and starting with it enabled
// would show a state the user cannot produce by hand and cannot fix from the chat.
//
this . selectedToolIds = await this . ToolRegistry . FilterSelectableToolIdsAsync ( Tools . Components . CHAT , templateToolIds );
}
/// <summary>
/// The data source options a chat starts with: those of its chat template, or the chat defaults.
/// </summary>
/// <remarks>
/// As with the tools, a template which carries no options says nothing and leaves the chat
/// defaults in place. A template which carries them answers more than which sources to search:
/// whether data sources are used at all, and whether an agent picks them for each message.
/// </remarks>
private DataSourceOptions GetAutomaticDataSourceOptions () =>
this . currentChatTemplate . DataSourceOptions ?. CreateCopy () ?? this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ();
private void ApplyAutomaticDataSourceOptions ()
2025-02-15 15:41:12 +01:00
{
2026-09-22 16:39:17 +02:00
var automaticOptions = this . GetAutomaticDataSourceOptions ();
this . lastAppliedAutomaticDataSourceOptions = automaticOptions . CreateCopy ();
this . earlyDataSourceOptions = automaticOptions ;
2025-03-08 14:23:24 +01:00
if ( this . ChatThread is not null )
2026-09-22 16:39:17 +02:00
this . ChatThread . DataSourceOptions = automaticOptions ;
this . dataSourceSelectionComponent ?. ChangeOptionWithoutSaving ( automaticOptions );
2025-02-15 15:41:12 +01:00
}
2026-07-05 14:46:31 +02:00
2026-09-22 16:39:17 +02:00
private async Task ApplyUpdatedAutomaticDataSourceOptionsAfterConfigurationChange ()
2026-07-05 14:46:31 +02:00
{
2026-09-22 16:39:17 +02:00
//
// What a chat would start with right now. The chat template is asked first, so that editing
// the template of the current chat reaches it — a change of the chat defaults it does not
// use would say nothing about it.
//
var updatedAutomaticOptions = this . GetAutomaticDataSourceOptions ();
var previousAutomaticOptions = this . lastAppliedAutomaticDataSourceOptions ;
this . lastAppliedAutomaticDataSourceOptions = updatedAutomaticOptions . CreateCopy ();
2026-07-05 14:46:31 +02:00
if ( this . ChatThread is null )
{
2026-09-22 16:39:17 +02:00
this . earlyDataSourceOptions = updatedAutomaticOptions ;
this . dataSourceSelectionComponent ?. ChangeOptionWithoutSaving ( updatedAutomaticOptions );
2026-07-05 14:46:31 +02:00
return ;
}
2026-09-22 16:39:17 +02:00
if (! DataSourceOptionsAreEqual ( this . ChatThread . DataSourceOptions , previousAutomaticOptions ))
2026-07-05 14:46:31 +02:00
return ;
2026-09-22 16:39:17 +02:00
await this . SetCurrentDataSourceOptions ( updatedAutomaticOptions );
this . dataSourceSelectionComponent ?. ChangeOptionWithoutSaving ( updatedAutomaticOptions , this . ChatThread . AISelectedDataSources );
2026-07-05 14:46:31 +02:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
private static bool DataSourceOptionsAreEqual ( DataSourceOptions left , DataSourceOptions right )
{
return left . DisableDataSources == right . DisableDataSources
&& left . AutomaticDataSourceSelection == right . AutomaticDataSourceSelection
&& left . AutomaticValidation == right . AutomaticValidation
&& left . PreselectedDataSourceIds . ToHashSet ( StringComparer . Ordinal ). SetEquals ( right . PreselectedDataSourceIds );
}
2025-02-15 15:41:12 +01:00
2025-01-02 13:16:47 +01:00
private string ExtractThreadName ( string firstUserInput )
{
// We select the first 10 words of the user input:
var words = firstUserInput . Split ( ' ' , StringSplitOptions . RemoveEmptyEntries );
var threadName = string . Join ( ' ' , words . Take ( 10 ));
2025-05-03 16:25:51 +02:00
threadName = threadName . Trim ();
// Remove all line breaks:
threadName = threadName . Replace ( "\r" , string . Empty );
threadName = threadName . Replace ( "\n" , " " );
threadName = threadName . Replace ( "\t" , " " );
2025-01-02 13:16:47 +01:00
// If the thread name is empty, we use a default name:
if ( string . IsNullOrWhiteSpace ( threadName ))
threadName = "Thread" ;
return threadName ;
}
private async Task ProfileWasChanged ( Profile profile )
{
2026-06-10 21:01:27 +02:00
this . currentProfile = this . SettingsManager . GetProfileById ( profile . Id );
2025-01-02 13:16:47 +01:00
2026-09-13 14:17:25 +02:00
//
// A thread which already exists has to carry the choice. Before the first message there is
// none, and the choice then travels in the thread a new chat is started with.
//
if ( this . ChatThread is not null )
2025-01-02 13:16:47 +01:00
{
2026-09-13 14:17:25 +02:00
this . ChatThread = this . ChatThread with
{
SelectedProfile = this . currentProfile . Id ,
};
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
2025-01-02 13:16:47 +01:00
}
2025-05-24 12:27:00 +02:00
private async Task ChatTemplateWasChanged ( ChatTemplate chatTemplate )
{
2026-06-10 21:01:27 +02:00
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( chatTemplate . Id );
2025-08-18 20:40:52 +02:00
if (! string . IsNullOrWhiteSpace ( this . currentChatTemplate . PredefinedUserPrompt ))
2026-05-25 20:48:26 +02:00
this . ComposerState . SetSystemInput ( this . currentChatTemplate . PredefinedUserPrompt );
2026-01-01 16:47:15 +01:00
// Apply template's file attachments (replaces existing):
2026-05-25 20:48:26 +02:00
this . ComposerState . ReplaceFileAttachments ( this . currentChatTemplate . FileAttachments );
2026-01-01 16:47:15 +01:00
2026-09-13 14:17:25 +02:00
if ( this . ChatThread is not null )
2026-09-22 16:39:17 +02:00
{
// Starting the new chat is what hands the selection of the new template to it:
2026-09-13 14:17:25 +02:00
await this . StartNewChat ( true );
2026-09-22 16:39:17 +02:00
return ;
}
//
// Without a thread there is nothing to start anew, so the selection of the new template is
// applied right here. It travels into the thread which the first message creates.
//
await this . ApplyChatTemplateToolSelectionAsync ();
this . ApplyAutomaticDataSourceOptions ();
2025-05-24 12:27:00 +02:00
}
2025-01-02 13:16:47 +01:00
2026-06-10 21:01:27 +02:00
private void RefreshCurrentProfileAndChatTemplate ()
{
this . currentProfile = this . SettingsManager . GetProfileById ( this . currentProfile . Id );
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( this . currentChatTemplate . Id );
}
2026-06-21 15:59:23 +02:00
private async Task RefreshChatSelectionsAfterConfigurationChange ()
{
var previousProvider = this . Provider ;
var previousChatTemplate = this . currentChatTemplate ;
2026-09-04 15:48:07 +02:00
this . Provider = this . SettingsManager . GetChatProviderForLoadedChat ( this . Provider . Id );
2026-06-21 15:59:23 +02:00
if ( this . Provider != previousProvider )
await this . ProviderChanged . InvokeAsync ( this . Provider );
if ( this . ChatThread is null )
{
this . currentProfile = this . SettingsManager . GetPreselectedProfile ( Tools . Components . CHAT );
this . currentChatTemplate = this . SettingsManager . GetPreselectedChatTemplate ( Tools . Components . CHAT );
}
else
{
this . currentProfile = string . IsNullOrWhiteSpace ( this . ChatThread . SelectedProfile )
? this . SettingsManager . GetProfileById ( this . currentProfile . Id )
: this . SettingsManager . GetProfileById ( this . ChatThread . SelectedProfile );
this . currentChatTemplate = string . IsNullOrWhiteSpace ( this . ChatThread . SelectedChatTemplate )
? this . SettingsManager . GetChatTemplateById ( this . currentChatTemplate . Id )
: this . SettingsManager . GetChatTemplateById ( this . ChatThread . SelectedChatTemplate );
}
if (! this . ComposerState . HasUserDraft && previousChatTemplate != this . currentChatTemplate )
this . ComposerState . ApplyTemplate ( this . currentChatTemplate );
2026-07-05 14:46:31 +02:00
2026-09-22 16:39:17 +02:00
await this . ApplyUpdatedAutomaticDataSourceOptionsAfterConfigurationChange ();
2026-06-21 15:59:23 +02:00
}
2025-02-17 12:33:34 +01:00
private IReadOnlyList < DataSourceAgentSelected > GetAgentSelectedDataSources ()
{
if ( this . ChatThread is null )
return [];
return this . ChatThread . AISelectedDataSources ;
}
2025-02-15 15:41:12 +01:00
private DataSourceOptions GetCurrentDataSourceOptions ()
{
if ( this . ChatThread is not null )
return this . ChatThread . DataSourceOptions ;
return this . earlyDataSourceOptions ;
}
private async Task SetCurrentDataSourceOptions ( DataSourceOptions updatedOptions )
{
if ( this . ChatThread is not null )
{
this . hasUnsavedChanges = true ;
this . ChatThread . DataSourceOptions = updatedOptions ;
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
await this . SaveThread ();
this . hasUnsavedChanges = false ;
}
}
else
this . earlyDataSourceOptions = updatedOptions ;
}
2025-03-08 20:13:08 +01:00
private bool IsInputForbidden ()
{
if (! this . IsProviderSelected )
return true ;
2026-05-24 13:50:42 +02:00
if ( this . IsCurrentChatStreaming )
2025-03-08 20:13:08 +01:00
return true ;
if (! this . ChatThread . IsLLMProviderAllowed ( this . Provider ))
return true ;
return false ;
}
2025-01-02 13:16:47 +01:00
private async Task InputKeyEvent ( KeyboardEventArgs keyEvent )
{
2025-02-15 15:41:12 +01:00
if ( this . dataSourceSelectionComponent ?. IsVisible ?? false )
this . dataSourceSelectionComponent . Hide ();
2025-01-02 13:16:47 +01:00
this . hasUnsavedChanges = true ;
2026-05-25 20:48:26 +02:00
this . ComposerState . MarkUserDraft ();
2025-01-02 13:16:47 +01:00
var key = keyEvent . Code . ToLowerInvariant ();
// Was the enter key (either enter or numpad enter) pressed?
var isEnter = key is "enter" or "numpadenter" ;
// Was a modifier key pressed as well?
var isModifier = keyEvent . AltKey || keyEvent . CtrlKey || keyEvent . MetaKey || keyEvent . ShiftKey ;
2026-09-13 14:17:25 +02:00
2025-01-02 13:16:47 +01:00
// Depending on the user's settings, might react to shortcuts:
switch ( this . SettingsManager . ConfigurationData . Chat . ShortcutSendBehavior )
{
case SendBehavior . ENTER_IS_SENDING :
if (! isModifier && isEnter )
await this . SendMessage ();
break ;
case SendBehavior . MODIFER_ENTER_IS_SENDING :
if ( isEnter && isModifier )
await this . SendMessage ();
break ;
}
}
2026-03-14 12:40:40 +01:00
private async Task ApplyMarkdownFormat ( string formatType )
{
if ( this . IsInputForbidden ())
return ;
if ( this . dataSourceSelectionComponent ?. IsVisible ?? false )
this . dataSourceSelectionComponent . Hide ();
2026-05-25 20:48:26 +02:00
this . ComposerState . SetUserInput ( await this . JsRuntime . InvokeAsync < string >( "formatChatInputMarkdown" , CHAT_INPUT_ID , formatType ));
this . hasUnsavedChanges = true ;
}
private void ComposerAttachmentsChanged ( HashSet < FileAttachment > attachments )
{
if (! ReferenceEquals ( this . ComposerState . FileAttachments , attachments ))
this . ComposerState . ReplaceFileAttachments ( attachments );
this . ComposerState . MarkUserDraft ();
2026-03-14 12:40:40 +01:00
this . hasUnsavedChanges = true ;
}
2026-07-15 12:53:30 +02:00
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
private async Task < ChatThread ?> EnsureMediaImportChatAsync ( string firstMediaPath )
{
if ( this . ChatThread is not null )
return this . ChatThread ;
this . RefreshCurrentProfileAndChatTemplate ();
var promptName = this . ExtractThreadName ( this . ComposerState . UserInput );
2026-09-13 14:17:25 +02:00
var threadName = string . IsNullOrWhiteSpace ( this . ComposerState . UserInput )
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName ;
this . ChatThread = this . NewChatThread ( threadName ) with
2026-07-15 12:53:30 +02:00
{
DataSourceOptions = this . earlyDataSourceOptions ,
};
await WorkspaceBehaviour . StoreChatAsync ( this . ChatThread );
this . MarkCurrentChatAsLoadedParameter ();
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
await this . SyncForegroundChatAsync ();
return this . ChatThread ;
}
2025-01-02 13:16:47 +01:00
2025-01-03 21:18:27 +01:00
private async Task SendMessage ( bool reuseLastUserPrompt = false )
2025-01-02 13:16:47 +01:00
{
2026-07-15 12:53:30 +02:00
if ( this . MediaTranscriptionService . IsBusy ( this . CurrentMediaImportOwner ))
return ;
2026-09-04 15:48:07 +02:00
await this . RefreshProviderSelectionFromConfigurationAsync ();
2025-01-02 13:16:47 +01:00
if (! this . IsProviderSelected )
return ;
2025-03-08 20:13:08 +01:00
if (! this . ChatThread . IsLLMProviderAllowed ( this . Provider ))
return ;
2026-06-10 21:01:27 +02:00
this . RefreshCurrentProfileAndChatTemplate ();
2026-03-05 18:37:18 +01:00
// Blur the focus away from the input field to be able to clear it:
2025-01-02 13:16:47 +01:00
await this . inputField . BlurAsync ();
// Create a new chat thread if necessary:
if ( this . ChatThread is null )
{
2026-09-13 14:17:25 +02:00
this . ChatThread = this . NewChatThread ( this . ExtractThreadName ( this . ComposerState . UserInput )) with
2025-01-02 13:16:47 +01:00
{
2025-02-15 15:41:12 +01:00
DataSourceOptions = this . earlyDataSourceOptions ,
2025-01-02 13:16:47 +01:00
};
2026-09-13 14:17:25 +02:00
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2025-01-02 13:16:47 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
else
{
// Set the thread name if it is empty:
if ( string . IsNullOrWhiteSpace ( this . ChatThread . Name ))
2026-05-25 20:48:26 +02:00
this . ChatThread . Name = this . ExtractThreadName ( this . ComposerState . UserInput );
2025-01-02 14:50:54 +01:00
2025-05-24 12:27:00 +02:00
// Update provider, profile and chat template:
2025-01-02 14:50:54 +01:00
this . ChatThread . SelectedProvider = this . Provider . Id ;
this . ChatThread . SelectedProfile = this . currentProfile . Id ;
2025-05-24 19:11:28 +02:00
//
// Remark: We do not update the chat template here
// because the chat template is only used when starting a new chat.
// Updating the chat template afterward is not supported.
//
2025-01-02 13:16:47 +01:00
}
2025-01-03 21:18:27 +01:00
2025-01-02 13:16:47 +01:00
var time = DateTimeOffset . Now ;
2025-02-10 18:49:22 +01:00
IContent ? lastUserPrompt ;
2025-01-03 21:18:27 +01:00
if (! reuseLastUserPrompt )
2025-01-02 13:16:47 +01:00
{
2026-05-25 20:48:26 +02:00
var normalizedAttachments = this . ComposerState . FileAttachments
2026-04-09 16:01:47 +02:00
. Select ( attachment => attachment . Normalize ())
. Where ( attachment => attachment . IsValid )
. ToList ();
2025-02-10 18:49:22 +01:00
lastUserPrompt = new ContentText
{
2026-05-25 20:48:26 +02:00
Text = this . ComposerState . UserInput ,
2026-04-09 16:01:47 +02:00
FileAttachments = normalizedAttachments ,
2025-02-10 18:49:22 +01:00
};
2026-07-15 12:53:30 +02:00
this . ChatThread . PendingMediaTranscripts . Clear ();
2025-02-10 18:49:22 +01:00
2025-01-03 21:18:27 +01:00
//
// Add the user message to the thread:
//
this . ChatThread ?. Blocks . Add ( new ContentBlock
2025-01-02 13:16:47 +01:00
{
2025-01-03 21:18:27 +01:00
Time = time ,
ContentType = ContentType . TEXT ,
Role = ChatRole . USER ,
2025-02-10 18:49:22 +01:00
Content = lastUserPrompt ,
2025-01-03 21:18:27 +01:00
});
2025-01-02 13:16:47 +01:00
2025-01-03 21:18:27 +01:00
// Save the chat:
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
await this . SaveThread ();
this . hasUnsavedChanges = false ;
this . StateHasChanged ();
}
2025-01-02 13:16:47 +01:00
}
2025-02-10 18:49:22 +01:00
else
2026-09-14 11:20:57 +02:00
{
//
// Regenerating asks again with the prompt that led to this answer. A thread which never
// carried one -- a chat template whose example conversation holds AI blocks only -- has
// nothing to reuse here. That is no reason to fail: the thread itself is what the model
// is given, and everything downstream already reads a missing prompt as "no data source
// lookup, just answer again".
//
lastUserPrompt = this . ChatThread . Blocks . LastOrDefault ( x => x . Role is ChatRole . USER )?. Content ;
}
2025-01-02 13:16:47 +01:00
//
// Add the AI response to the thread:
//
var aiText = new ContentText
{
// We have to wait for the remote
// for the content stream:
InitialRemoteWait = true ,
};
this . ChatThread ?. Blocks . Add ( new ContentBlock
{
Time = time ,
ContentType = ContentType . TEXT ,
Role = ChatRole . AI ,
Content = aiText ,
});
// Clear the input field:
2025-07-02 14:01:10 +02:00
await this . inputField . FocusAsync ();
2026-03-05 18:37:18 +01:00
2026-05-25 20:48:26 +02:00
this . ComposerState . Clear ();
2026-03-05 18:37:18 +01:00
2025-07-02 14:01:10 +02:00
await this . inputField . BlurAsync ();
2026-09-13 14:17:25 +02:00
2025-01-02 13:16:47 +01:00
// Enable the stream state for the chat component:
this . hasUnsavedChanges = true ;
if ( this . SettingsManager . ConfigurationData . Chat . ShowLatestMessageAfterLoading )
{
this . mustScrollToBottomAfterRender = true ;
this . scrollRenderCountdown = 2 ;
}
this . Logger . LogDebug ( $"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'." );
2026-09-04 15:48:07 +02:00
this . StateHasChanged ();
this . ChatThread !. RuntimeComponent = Tools . Components . CHAT ;
this . ChatThread . SelectedToolIds = [.. this . selectedToolIds ];
this . ChatThread . RuntimeSelectedToolIds = this . ToolRegistry . FilterToolIdsForProvider ( this . Provider , this . selectedToolIds );
2026-05-24 13:50:42 +02:00
await this . AIJobService . TryStartChatGenerationAsync ( new ChatGenerationRequest
2025-01-03 23:13:56 +01:00
{
2026-09-04 15:48:07 +02:00
ChatThread = this . ChatThread ,
2026-05-24 13:50:42 +02:00
AIText = aiText ,
LastUserPrompt = lastUserPrompt ,
ProviderSettings = this . Provider ,
IsForeground = true ,
});
await this . SyncForegroundChatAsync ();
2025-01-02 13:16:47 +01:00
this . StateHasChanged ();
}
2025-01-03 23:13:56 +01:00
private async Task CancelStreaming ()
{
2026-05-24 13:50:42 +02:00
if ( this . ChatThread is not null )
await this . AIJobService . CancelChatGenerationAsync ( this . ChatThread . ChatId );
2025-01-03 23:13:56 +01:00
}
2026-09-04 15:48:07 +02:00
/// <summary>
/// Takes over the tool selection of the chat that was just loaded or handed to this component.
/// </summary>
/// <remarks>
/// A thread without a selection means the chat defaults: that is a chat saved before tools
/// existed, as well as one a launcher opened without naming any. Both want what the settings
/// preselect. Every path that puts a thread into this component has to come through here, or
/// the footer would keep showing the tools of the chat before it.
/// </remarks>
private void ApplyToolSelectionOfLoadedChat () =>
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . ChatThread ?. SelectedToolIds ?? this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ));
2026-09-13 14:17:25 +02:00
private void SelectedToolIdsChanged ( HashSet < string > updatedToolIds )
2026-09-04 15:48:07 +02:00
{
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( updatedToolIds );
//
// The thread keeps the selection so that reopening the chat tomorrow brings the same tools
// back. What is stored is what the user chose, not what the current provider is allowed to
// run: filtering here would quietly drop a tool for good the moment the user switches to a
// provider with less confidence.
//
if ( this . ChatThread is not null )
{
this . ChatThread . SelectedToolIds = [.. this . selectedToolIds ];
this . hasUnsavedChanges = true ;
}
}
2025-01-03 23:13:56 +01:00
2025-01-02 13:16:47 +01:00
private async Task SaveThread ()
{
if ( this . ChatThread is null )
return ;
if (! this . CanThreadBeSaved )
return ;
//
// When the workspace component is visible, we store the chat
// through the workspace component. The advantage of this is that
// the workspace gets updated automatically when the chat is saved.
//
if ( this . Workspaces is not null )
2026-03-05 18:37:18 +01:00
await this . Workspaces . StoreChatAsync ( this . ChatThread );
2025-01-02 13:16:47 +01:00
else
2026-03-05 18:37:18 +01:00
await WorkspaceBehaviour . StoreChatAsync ( this . ChatThread );
2025-01-02 13:16:47 +01:00
this . hasUnsavedChanges = false ;
}
private async Task StartNewChat ( bool useSameWorkspace = false , bool deletePreviousChat = false )
{
2025-02-15 15:41:12 +01:00
//
// Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss:
//
2026-05-24 13:50:42 +02:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_MANUALLY && this . hasUnsavedChanges && ! this . IsCurrentChatStreaming )
2025-01-02 13:16:47 +01:00
{
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2025-01-02 13:16:47 +01:00
{
2026-09-22 17:50:24 +02:00
{ x => x . Message , T ( "Are you sure you want to start a new chat? All unsaved changes will be lost." ) },
2025-01-02 13:16:47 +01:00
};
2026-09-22 17:50:24 +02:00
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Start New Chat" ), dialogParameters , DialogOptions . FULLSCREEN );
2025-01-02 13:16:47 +01:00
var dialogResult = await dialogReference . Result ;
if ( dialogResult is null || dialogResult . Canceled )
return ;
}
2025-02-15 15:41:12 +01:00
//
// Delete the previous chat when desired and necessary:
//
2025-01-02 13:16:47 +01:00
if ( this . ChatThread is not null && deletePreviousChat )
{
2026-09-22 17:50:24 +02:00
//
// A deleted chat cannot be restored, and the check above never covers this path: it
// exists only while chats are stored automatically, while that check runs only while
// they are stored manually. So we let the deletion itself ask, with the question the
// chat list asks. When it reports the chat is still there, the user declined or the
// chat is busy, and we stop before the reset below takes it out of view:
//
bool chatIsGone ;
if ( this . Workspaces is null )
chatIsGone = await WorkspaceBehaviour . DeleteChatAsync ( this . DialogService , this . ChatThread . WorkspaceId , this . ChatThread . ChatId );
2025-01-02 13:16:47 +01:00
else
2026-09-22 17:50:24 +02:00
{
var chatPath = this . ChatThread . WorkspaceId == Guid . Empty
? Path . Join ( SettingsManager . DataDirectory , "tempChats" , this . ChatThread . ChatId . ToString ())
: Path . Join ( SettingsManager . DataDirectory , "workspaces" , this . ChatThread . WorkspaceId . ToString (), this . ChatThread . ChatId . ToString ());
2025-01-02 13:16:47 +01:00
2026-09-22 17:50:24 +02:00
chatIsGone = await this . Workspaces . DeleteChatAsync ( chatPath , unloadChat : true );
}
if (! chatIsGone )
return ;
2025-01-02 13:16:47 +01:00
}
2025-02-15 15:41:12 +01:00
//
// Reset our state:
//
2025-01-02 13:16:47 +01:00
this . hasUnsavedChanges = false ;
2026-05-25 20:48:26 +02:00
this . ComposerState . Clear ();
2026-06-10 21:01:27 +02:00
this . RefreshCurrentProfileAndChatTemplate ();
2025-01-02 13:16:47 +01:00
2025-02-15 15:41:12 +01:00
//
// Reset the LLM provider considering the user's settings:
//
2025-01-02 13:16:47 +01:00
switch ( this . SettingsManager . ConfigurationData . Chat . AddChatProviderBehavior )
{
case AddChatProviderBehavior . ADDED_CHATS_USE_DEFAULT_PROVIDER :
this . Provider = this . SettingsManager . GetPreselectedProvider ( Tools . Components . CHAT );
await this . ProviderChanged . InvokeAsync ( this . Provider );
break ;
default :
case AddChatProviderBehavior . ADDED_CHATS_USE_LATEST_PROVIDER :
2025-08-26 10:59:56 +02:00
if ( this . Provider == AIStudio . Settings . Provider . NONE )
2025-01-02 13:16:47 +01:00
{
this . Provider = this . SettingsManager . GetPreselectedProvider ( Tools . Components . CHAT );
await this . ProviderChanged . InvokeAsync ( this . Provider );
}
break ;
}
2025-02-15 15:41:12 +01:00
//
// Reset the chat thread or create a new one:
//
2025-01-02 13:16:47 +01:00
if (! useSameWorkspace )
{
2025-02-15 15:41:12 +01:00
//
// When the user wants to start a new chat outside the current workspace,
// we have to reset the workspace id and the workspace name. Also, we have
// to reset the chat thread:
//
2025-01-02 13:16:47 +01:00
this . ChatThread = null ;
2026-04-15 09:44:12 +02:00
this . ClearWorkspaceHeaderState ();
2025-01-02 13:16:47 +01:00
}
else
{
2025-02-15 15:41:12 +01:00
//
// When the user wants to start a new chat in the same workspace, we have to
// reset the chat thread only. The workspace id and the workspace name remain
// the same:
//
2026-09-13 14:17:25 +02:00
this . ChatThread = this . NewChatThread ( string . Empty );
2025-01-02 13:16:47 +01:00
}
2026-01-01 16:47:15 +01:00
2026-05-25 20:48:26 +02:00
this . ComposerState . ApplyTemplate ( this . currentChatTemplate );
2026-01-01 16:47:15 +01:00
2026-09-22 16:39:17 +02:00
// Now, the chat starts with what its template asks for, and with the chat defaults wherever
// that template says nothing:
await this . ApplyChatTemplateToolSelectionAsync ();
this . ApplyAutomaticDataSourceOptions ();
2025-02-15 15:41:12 +01:00
// Notify the parent component about the change:
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2025-01-02 13:16:47 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
2026-09-13 14:17:25 +02:00
2025-01-02 13:16:47 +01:00
private async Task MoveChatToWorkspace ()
{
if ( this . ChatThread is null )
return ;
2026-05-24 13:50:42 +02:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_MANUALLY && this . hasUnsavedChanges && ! this . IsCurrentChatStreaming )
2025-01-02 13:16:47 +01:00
{
2025-08-28 18:51:44 +02:00
var confirmationDialogParameters = new DialogParameters < ConfirmDialog >
2025-01-02 13:16:47 +01:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , T ( "Are you sure you want to move this chat? All unsaved changes will be lost." ) },
2025-01-02 13:16:47 +01:00
};
2026-09-22 17:50:24 +02:00
var confirmationDialogReference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Unsaved Changes" ), confirmationDialogParameters , DialogOptions . FULLSCREEN );
2025-01-02 13:16:47 +01:00
var confirmationDialogResult = await confirmationDialogReference . Result ;
if ( confirmationDialogResult is null || confirmationDialogResult . Canceled )
return ;
}
2025-08-28 18:51:44 +02:00
var dialogParameters = new DialogParameters < WorkspaceSelectionDialog >
2025-01-02 13:16:47 +01:00
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , T ( "Please select the workspace where you want to move the chat to." ) },
2025-08-31 14:45:03 +02:00
{ x => x . SelectedWorkspace , this . ChatThread ?. WorkspaceId ?? Guid . Empty },
2025-08-28 18:51:44 +02:00
{ x => x . ConfirmText , T ( "Move chat" ) },
2025-01-02 13:16:47 +01:00
};
2026-06-06 10:06:41 +02:00
var dialogReference = await this . DialogService . ShowAsync < WorkspaceSelectionDialog >( T ( "Move Chat to Workspace" ), dialogParameters , DialogOptions . FULLSCREEN_MANUAL_ESCAPE );
2025-01-02 13:16:47 +01:00
var dialogResult = await dialogReference . Result ;
if ( dialogResult is null || dialogResult . Canceled )
return ;
var workspaceId = dialogResult . Data is Guid id ? id : Guid . Empty ;
if ( workspaceId == Guid . Empty )
return ;
2026-07-15 12:53:30 +02:00
await WorkspaceBehaviour . MoveChatAsync ( this . ChatThread !, workspaceId );
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2026-04-15 09:44:12 +02:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
2025-01-02 13:16:47 +01:00
}
2026-05-25 20:48:26 +02:00
private async Task LoadedChatChanged ( bool notifyParent = true )
2025-01-02 13:16:47 +01:00
{
this . hasUnsavedChanges = false ;
2026-05-25 20:48:26 +02:00
this . ComposerState . Clear ();
2025-01-21 15:36:22 +01:00
if ( this . ChatThread is not null )
{
2026-05-24 13:50:42 +02:00
this . ChatThread = this . AIJobService . TryGetLiveChatThread ( this . ChatThread . ChatId ) ?? this . ChatThread ;
2026-05-25 20:48:26 +02:00
this . loadedParameterChatId = this . ChatThread . ChatId ;
this . loadedParameterWorkspaceId = this . ChatThread . WorkspaceId ;
if ( notifyParent )
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
2026-04-15 09:44:12 +02:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ();
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2025-02-17 12:33:34 +01:00
this . dataSourceSelectionComponent ?. ChangeOptionWithoutSaving ( this . ChatThread . DataSourceOptions , this . ChatThread . AISelectedDataSources );
2026-09-04 15:48:07 +02:00
this . ApplyToolSelectionOfLoadedChat ();
2025-01-21 15:36:22 +01:00
}
else
{
2026-05-25 20:48:26 +02:00
this . loadedParameterChatId = Guid . Empty ;
this . loadedParameterWorkspaceId = Guid . Empty ;
2026-04-15 09:44:12 +02:00
this . ClearWorkspaceHeaderState ();
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2026-09-22 16:39:17 +02:00
this . ApplyAutomaticDataSourceOptions ();
2025-01-21 15:36:22 +01:00
}
2026-09-22 16:39:17 +02:00
2025-01-02 13:16:47 +01:00
await this . SelectProviderWhenLoadingChat ();
if ( this . SettingsManager . ConfigurationData . Chat . ShowLatestMessageAfterLoading )
{
this . mustScrollToBottomAfterRender = true ;
this . scrollRenderCountdown = 2 ;
}
2025-01-21 19:12:36 +01:00
this . StateHasChanged ();
2025-01-02 13:16:47 +01:00
}
2026-09-04 15:48:07 +02:00
private async Task RefreshProviderSelectionFromConfigurationAsync ()
{
var updatedProvider = this . SettingsManager . GetPreselectedProvider ( Tools . Components . CHAT , this . Provider . Id );
var providerChanged = updatedProvider != this . Provider ;
if ( providerChanged )
this . Provider = updatedProvider ;
if (! providerChanged )
return ;
await this . ProviderChanged . InvokeAsync ( this . Provider );
}
2025-01-02 13:16:47 +01:00
private async Task ResetState ()
{
this . hasUnsavedChanges = false ;
2026-05-25 20:48:26 +02:00
this . ComposerState . Clear ();
2026-04-15 09:44:12 +02:00
this . ClearWorkspaceHeaderState ();
2025-01-02 13:16:47 +01:00
this . ChatThread = null ;
2026-05-25 20:48:26 +02:00
this . MarkCurrentChatAsLoadedParameter ();
2026-05-24 13:50:42 +02:00
await this . SyncForegroundChatAsync ();
2026-09-22 16:39:17 +02:00
this . ApplyAutomaticDataSourceOptions ();
2025-01-02 13:16:47 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
2026-09-13 14:17:25 +02:00
2025-01-02 13:16:47 +01:00
private async Task SelectProviderWhenLoadingChat ()
{
var chatProvider = this . ChatThread ?. SelectedProvider ;
2025-01-02 14:50:54 +01:00
var chatProfile = this . ChatThread ?. SelectedProfile ;
2025-05-24 12:27:00 +02:00
var chatChatTemplate = this . ChatThread ?. SelectedChatTemplate ;
2025-01-04 19:27:45 +01:00
2026-04-16 09:09:05 +02:00
this . Provider = this . SettingsManager . GetChatProviderForLoadedChat ( chatProvider );
2025-01-02 13:16:47 +01:00
await this . ProviderChanged . InvokeAsync ( this . Provider );
2025-01-02 14:50:54 +01:00
// Try to select the profile:
if (! string . IsNullOrWhiteSpace ( chatProfile ))
2026-06-10 21:01:27 +02:00
this . currentProfile = this . SettingsManager . GetProfileById ( chatProfile );
2025-05-24 12:27:00 +02:00
// Try to select the chat template:
if (! string . IsNullOrWhiteSpace ( chatChatTemplate ))
2026-06-10 21:01:27 +02:00
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( chatChatTemplate );
2025-01-02 13:16:47 +01:00
}
private async Task ToggleWorkspaceOverlay ()
{
await MessageBus . INSTANCE . SendMessage < bool >( this , Event . WORKSPACE_TOGGLE_OVERLAY );
}
2025-01-03 18:01:22 +01:00
private async Task RemoveBlock ( IContent block )
{
if ( this . ChatThread is null )
return ;
this . ChatThread . Remove ( block );
this . hasUnsavedChanges = true ;
await this . SaveThread ();
this . StateHasChanged ();
}
2025-01-03 21:18:27 +01:00
private async Task RegenerateBlock ( IContent aiBlock )
{
if ( this . ChatThread is null )
return ;
2025-03-08 20:13:08 +01:00
if (! this . ChatThread . IsLLMProviderAllowed ( this . Provider ))
return ;
2025-01-03 21:18:27 +01:00
this . ChatThread . Remove ( aiBlock , removeForRegenerate : true );
this . hasUnsavedChanges = true ;
this . StateHasChanged ();
await this . SendMessage ( reuseLastUserPrompt : true );
}
2025-01-03 18:01:22 +01:00
2025-01-03 21:58:31 +01:00
private Task EditLastUserBlock ( IContent block )
{
if ( this . ChatThread is null )
return Task . CompletedTask ;
2026-09-13 14:17:25 +02:00
2025-01-03 21:58:31 +01:00
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
2026-09-13 14:17:25 +02:00
2025-01-03 21:58:31 +01:00
var lastBlock = this . ChatThread . Blocks . Last ();
var lastBlockContent = lastBlock . Content ;
if ( lastBlockContent is null )
return Task . CompletedTask ;
2026-09-13 14:17:25 +02:00
2026-05-22 16:18:36 +02:00
this . RestoreComposerFromTextBlock ( textBlock );
2025-01-03 21:58:31 +01:00
this . ChatThread . Remove ( block );
this . ChatThread . Remove ( lastBlockContent );
this . hasUnsavedChanges = true ;
this . StateHasChanged ();
return Task . CompletedTask ;
}
2026-09-13 14:17:25 +02:00
2025-01-03 21:58:31 +01:00
private Task EditLastBlock ( IContent block )
{
if ( this . ChatThread is null )
return Task . CompletedTask ;
2026-09-13 14:17:25 +02:00
2025-01-03 21:58:31 +01:00
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
2026-09-13 14:17:25 +02:00
2026-05-22 16:18:36 +02:00
this . RestoreComposerFromTextBlock ( textBlock );
2025-01-03 21:58:31 +01:00
this . ChatThread . Remove ( block );
this . hasUnsavedChanges = true ;
this . StateHasChanged ();
return Task . CompletedTask ;
}
2026-05-22 16:18:36 +02:00
private void RestoreComposerFromTextBlock ( ContentText textBlock )
{
2026-05-25 20:48:26 +02:00
this . ComposerState . RestoreFromTextBlock ( textBlock );
2026-05-22 16:18:36 +02:00
}
2025-01-03 21:58:31 +01:00
2026-09-13 14:17:25 +02:00
/// <summary>
/// Works out what the next request would take out of the model's context window.
/// </summary>
/// <remarks>
/// The whole conversation, not only what is being typed. A number counting the draft alone
/// answers a question nobody asks: what decides whether the next message fits is everything
/// which travels with it, and in a chat of any age the draft is the smallest part of that.
///
/// This used to run only for providers with a tokenizer of their own, which is almost nobody,
/// so almost nobody ever saw a number. The runtime falls back to the tokenizer shipped with AI
/// Studio when a provider names none, so the count is available everywhere -- it is then an
/// estimate, and it says so.
///
/// Read the text from the bound property rather than from the input field: the field is a
/// component reference, which is only set once the component has rendered.
///
/// Called by the tracker, never directly. Whoever thinks something changed nudges it instead,
/// and it decides when the work is worth doing.
/// </remarks>
/// <param name="token">Ends the count when the component goes away.</param>
private async Task RecountTokensAsync ( CancellationToken token )
2026-09-09 18:43:37 +02:00
{
2026-09-13 14:17:25 +02:00
var provider = AIStudio . Settings . Provider . NONE ;
var parts = ConversationParts . NOTHING ;
2026-09-09 18:43:37 +02:00
//
2026-09-13 14:17:25 +02:00
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
// and while it runs, the background job which writes the answer appends to the very list
// which is walked here.
2026-09-09 18:43:37 +02:00
//
2026-09-13 14:17:25 +02:00
await this . InvokeAsync (() =>
2026-09-09 18:43:37 +02:00
{
2026-09-13 14:17:25 +02:00
//
// Before the first message there is no thread yet, so what is measured is the one a new
// chat would start with. A preselected profile or a chat template is already part of
// that, and it may even bring an example conversation along -- reporting nothing for all
// of it would tell a person their window is empty while their first message is not.
//
var thread = this . ChatThread ?? this . NewChatThread ( string . Empty );
2026-09-14 19:54:23 +02:00
var toolDefinitions = this . GetRunnableToolDefinitions ();
2026-09-13 14:17:25 +02:00
provider = this . Provider ;
2026-09-14 19:54:23 +02:00
parts = ConversationParts . Of ( thread , this . BuildSystemPromptFor ( thread , toolDefinitions ), this . UserInput , this . ComposerState . FileAttachments , provider . SupportsImageInput (), toolDefinitions );
2026-09-13 14:17:25 +02:00
});
2026-09-09 18:43:37 +02:00
2026-09-13 14:17:25 +02:00
var counted = await this . ConversationTokenCounter . CountAsync ( provider , parts , token );
if ( token . IsCancellationRequested )
2026-09-09 18:43:37 +02:00
return ;
2026-09-13 14:17:25 +02:00
await this . InvokeAsync (() =>
2026-09-09 18:43:37 +02:00
{
2026-09-13 14:17:25 +02:00
if ( counted == this . conversationTokens )
return ;
this . conversationTokens = counted ;
this . StateHasChanged ();
});
2026-09-09 18:43:37 +02:00
}
2026-09-13 14:17:25 +02:00
/// <summary>
/// Works out the system prompt a thread would send.
/// </summary>
/// <remarks>
/// Not the prompt a person typed: a chat template may replace it, the retrieved data of a data
/// source is appended to it, the selected profile adds a paragraph, and the policy of the
/// selected tools adds another. Switching a profile while writing therefore moves the number,
/// which is the whole reason this is asked rather than read off the thread.
/// </remarks>
/// <param name="thread">The thread to build the prompt for.</param>
2026-09-14 19:54:23 +02:00
/// <param name="toolDefinitions">The tools whose policy the prompt states.</param>
2026-09-13 14:17:25 +02:00
/// <returns>The system prompt as it would be sent.</returns>
2026-09-14 19:54:23 +02:00
private string BuildSystemPromptFor ( ChatThread thread , IReadOnlyList < ToolDefinition > toolDefinitions ) => thread . BuildSystemPrompt ( this . SettingsManager , toolDefinitions ). Text ;
2026-09-13 14:17:25 +02:00
2026-09-14 19:54:23 +02:00
/// <summary>
/// The tools the next request would offer the model.
/// </summary>
/// <remarks>
/// Filtered for the provider the same way they are before sending, so that a tool the provider
/// is not trusted enough to receive does not count either.
///
/// Asked for once and used twice: their policy goes into the system prompt, and their schemas
/// travel next to it in the request body. Both cost tokens, and both change the moment somebody
/// switches a tool on.
/// </remarks>
/// <returns>The definitions of the selected tools.</returns>
private IReadOnlyList < ToolDefinition > GetRunnableToolDefinitions () => this . ToolRegistry . FilterToolIdsForProvider ( this . Provider , this . selectedToolIds )
. Select ( this . ToolRegistry . GetDefinition )
. Where ( definition => definition is not null )
. Select ( definition => definition !)
. ToList ();
2026-09-13 14:17:25 +02:00
/// <summary>
/// The thread a new chat starts with, as the selections made so far decide it.
/// </summary>
/// <remarks>
/// In one place because three code paths used to write it out, and because the token count has
/// to measure the same thing they build. A count against a thread assembled differently from
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
/// they send their first message.
///
/// The data source options are left out on purpose: two of the three callers set them and the
/// third replaces them right afterwards, so this stays the part they agree on.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread ( string name ) => new ()
{
IncludeDateTime = true ,
SelectedProvider = this . Provider . Id ,
SelectedProfile = this . currentProfile . Id ,
SelectedChatTemplate = this . currentChatTemplate . Id ,
SelectedToolIds = [.. this . selectedToolIds ],
SystemPrompt = SystemPrompts . DEFAULT ,
WorkspaceId = this . currentWorkspaceId ,
ChatId = Guid . NewGuid (),
Name = name ,
Blocks = this . currentChatTemplate == ChatTemplate . NO_CHAT_TEMPLATE ? [] : this . currentChatTemplate . ExampleConversation . Select ( block => block . DeepClone ()). ToList (),
};
2026-09-09 18:43:37 +02:00
2025-01-02 13:16:47 +01:00
#region Overrides of MSGComponentBase
2025-04-24 13:50:14 +02:00
2025-04-12 21:13:33 +02:00
protected override async Task ProcessIncomingMessage < T >( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
2025-01-02 13:16:47 +01:00
{
switch ( triggeredEvent )
{
case Event . RESET_CHAT_STATE :
await this . ResetState ();
break ;
case Event . CHAT_STREAMING_DONE :
2026-02-08 17:27:33 +01:00
// Streaming mutates the last AI block over time.
// In manual storage mode, a save during streaming must not
// mark the final streamed state as already persisted.
this . hasUnsavedChanges = true ;
2025-01-02 13:16:47 +01:00
if ( this . autoSaveEnabled )
await this . SaveThread ();
break ;
2026-06-06 10:06:41 +02:00
case Event . WORKSPACE_RENAMED :
if ( data is Guid workspaceId )
await this . RefreshRenamedWorkspaceHeaderAsync ( workspaceId );
break ;
2026-06-10 21:01:27 +02:00
case Event . CONFIGURATION_CHANGED :
2026-06-21 15:59:23 +02:00
case Event . PLUGINS_RELOADED :
2026-09-13 14:17:25 +02:00
await this . RefreshCulture ();
2026-06-21 15:59:23 +02:00
await this . RefreshChatSelectionsAfterConfigurationChange ();
2026-06-10 21:01:27 +02:00
this . StateHasChanged ();
break ;
2025-01-02 13:16:47 +01:00
2026-05-24 13:50:42 +02:00
case Event . AI_JOB_CHANGED :
case Event . AI_JOB_FINISHED :
case Event . CHAT_GENERATION_CHANGED :
if ( data is AIJobSnapshot { Kind : AIJobKind . CHAT_GENERATION } snapshot && this . ChatThread ?. ChatId == snapshot . SubjectId )
{
this . ChatThread = this . AIJobService . TryGetLiveChatThread ( snapshot . SubjectId ) ?? this . ChatThread ;
if (! snapshot . IsActive )
2026-06-23 09:02:40 +02:00
{
2026-05-24 13:50:42 +02:00
this . hasUnsavedChanges = false ;
2026-06-23 09:02:40 +02:00
this . previousInputForbidden = true ;
}
2026-05-24 13:50:42 +02:00
this . StateHasChanged ();
}
break ;
2026-09-04 15:48:07 +02:00
2025-01-02 13:16:47 +01:00
}
}
2025-04-12 21:13:33 +02:00
protected override Task < TResult ?> ProcessIncomingMessageWithResult < TPayload , TResult >( ComponentBase ? sendingComponent , Event triggeredEvent , TPayload ? data ) where TResult : default where TPayload : default
2025-01-02 13:16:47 +01:00
{
switch ( triggeredEvent )
{
case Event . HAS_CHAT_UNSAVED_CHANGES :
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
return Task . FromResult (( TResult ?) ( object ) false );
2026-05-24 13:50:42 +02:00
if ( this . IsCurrentChatStreaming )
return Task . FromResult (( TResult ?) ( object ) false );
2025-01-02 13:16:47 +01:00
2026-05-25 20:48:26 +02:00
return Task . FromResult (( TResult ?)( object )( this . hasUnsavedChanges || this . ComposerState . HasVisibleUserDraft ));
2025-01-02 13:16:47 +01:00
}
return Task . FromResult ( default ( TResult ));
}
#endregion
2026-08-23 21:15:37 +02:00
#region Overrides of MSGComponentBase
2025-01-02 13:16:47 +01:00
2026-08-23 21:15:37 +02:00
protected override async ValueTask DisposeResourcesAsync ()
2025-01-02 13:16:47 +01:00
{
2026-07-15 12:53:30 +02:00
this . MediaTranscriptionService . StateChanged -= this . OnMediaImportStateChanged ;
2026-09-13 14:17:25 +02:00
if ( this . tokenTracker is not null )
await this . tokenTracker . DisposeAsync ();
2025-01-02 13:16:47 +01:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
await this . SaveThread ();
this . hasUnsavedChanges = false ;
}
2025-01-03 23:13:56 +01:00
2026-05-24 13:50:42 +02:00
await this . AIJobService . SetForegroundAsync ( AIJobKind . CHAT_GENERATION , this . foregroundChatId , false );
2025-01-02 13:16:47 +01:00
}
#endregion
2026-09-04 15:48:07 +02:00
}