2026-09-13 12:17:25 +00:00
using System.Globalization ;
2025-01-02 12:16:47 +00:00
using AIStudio.Chat ;
using AIStudio.Dialogs ;
using AIStudio.Provider ;
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
2026-09-04 13:48:07 +00:00
using AIStudio.Tools.ToolCallingSystem ;
2026-05-24 11:50:42 +00:00
using AIStudio.Tools.AIJobs ;
2026-07-15 10:53:30 +00:00
using AIStudio.Tools.Media ;
using AIStudio.Tools.Services ;
2025-01-02 12:16:47 +00:00
using Microsoft.AspNetCore.Components ;
using Microsoft.AspNetCore.Components.Web ;
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
namespace AIStudio.Components ;
2026-08-23 19:15:37 +00:00
public partial class ChatComponent : MSGComponentBase
2025-01-02 12:16:47 +00:00
{
2026-07-15 10:53:30 +00:00
private readonly Guid draftMediaOwnerId = Guid . NewGuid ( ) ;
2026-03-14 11:40:40 +00: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 12:16:47 +00:00
[Parameter]
public ChatThread ? ChatThread { get ; set ; }
[Parameter]
public EventCallback < ChatThread ? > ChatThreadChanged { get ; set ; }
2025-08-26 08:59:56 +00:00
2025-01-02 12:16:47 +00:00
[Parameter]
2025-08-26 08:59:56 +00:00
public AIStudio . Settings . Provider Provider { get ; set ; } = AIStudio . Settings . Provider . NONE ;
2025-01-02 12:16:47 +00:00
[Parameter]
2025-01-05 14:11:15 +00:00
public EventCallback < AIStudio . Settings . Provider > ProviderChanged { get ; set ; }
2025-01-02 12:16:47 +00:00
[Parameter]
public Action < string > WorkspaceName { get ; set ; } = _ = > { } ;
[Parameter]
public Workspaces ? Workspaces { get ; set ; }
2026-05-25 18:48:26 +00:00
[Parameter]
public ChatComposerState ComposerState { get ; set ; } = new ( ) ;
2025-01-02 12:16:47 +00:00
[Inject]
private ILogger < ChatComponent > Logger { get ; set ; } = null ! ;
2025-12-10 12:48:13 +00:00
2026-09-04 13:48:07 +00:00
[Inject]
private ToolRegistry ToolRegistry { get ; set ; } = null ! ;
2025-01-02 12:16:47 +00:00
[Inject]
private IDialogService DialogService { get ; init ; } = null ! ;
2026-09-13 12:17:25 +00:00
[Inject]
private ConversationTokenCounter ConversationTokenCounter { get ; init ; } = null ! ;
2026-03-14 11:40:40 +00:00
[Inject]
private IJSRuntime JsRuntime { get ; init ; } = null ! ;
2025-12-10 12:48:13 +00:00
2026-05-24 11:50:42 +00:00
[Inject]
private AIJobService AIJobService { get ; init ; } = null ! ;
2026-07-15 10:53:30 +00:00
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get ; init ; } = null ! ;
2025-01-21 18:22:26 +00:00
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement . Top ;
2025-01-02 12:16:47 +00:00
private static readonly Dictionary < string , object? > USER_INPUT_ATTRIBUTES = new ( ) ;
2025-02-15 14:41:12 +00:00
private DataSourceSelection ? dataSourceSelectionComponent ;
private DataSourceOptions earlyDataSourceOptions = new ( ) ;
2026-07-05 12:46:31 +00:00
private DataSourceOptions lastAppliedStandardDataSourceOptions = new ( ) ;
2025-01-02 12:16:47 +00:00
private Profile currentProfile = Profile . NO_PROFILE ;
2025-05-24 17:11:28 +00:00
private ChatTemplate currentChatTemplate = ChatTemplate . NO_CHAT_TEMPLATE ;
2025-01-02 12:16:47 +00: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 13:48:07 +00:00
private HashSet < string > selectedToolIds = [ ] ;
2026-06-23 07:02:40 +00:00
private bool previousInputForbidden = true ;
private Guid lastSeenChatId = Guid . Empty ;
private AIStudio . Settings . Provider lastSeenProvider = AIStudio . Settings . Provider . NONE ;
2025-01-02 12:16:47 +00:00
private string currentWorkspaceName = string . Empty ;
private Guid currentWorkspaceId = Guid . Empty ;
2026-03-05 17:37:18 +00:00
private Guid currentChatThreadId = Guid . Empty ;
2026-05-25 18:48:26 +00:00
private Guid loadedParameterChatId = Guid . Empty ;
private Guid loadedParameterWorkspaceId = Guid . Empty ;
2026-05-24 11:50:42 +00:00
private Guid foregroundChatId = Guid . Empty ;
2026-04-15 07:44:12 +00:00
private int workspaceHeaderSyncVersion ;
2026-09-13 12:17:25 +00: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 10:27:00 +00:00
2026-07-15 10:53:30 +00:00
private MediaImportOwner CurrentMediaImportOwner = > MediaImportOwner . ForChat ( this . ChatThread ? . ChatId ? ? this . draftMediaOwnerId ) ;
2025-01-02 12:16:47 +00:00
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
2026-09-09 16:43:37 +00:00
private UserPromptComponent < string > inputField = null ! ;
2025-01-02 12:16:47 +00:00
2026-05-25 18:48:26 +00: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 12:16:47 +00:00
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ( )
{
2026-07-15 10:53:30 +00:00
this . MediaTranscriptionService . StateChanged + = this . OnMediaImportStateChanged ;
2026-09-13 12:17:25 +00: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 13:48:07 +00:00
2025-02-15 14:41:12 +00:00
// Apply the filters for the message bus:
2026-06-10 19:01:27 +00: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 12:16:47 +00:00
// Configure the spellchecking for the user input:
this . SettingsManager . InjectSpellchecking ( USER_INPUT_ATTRIBUTES ) ;
2026-03-14 11:40:40 +00:00
USER_INPUT_ATTRIBUTES [ "id" ] = CHAT_INPUT_ID ;
2025-02-15 14:41:12 +00:00
// Get the preselected profile:
2025-01-02 12:16:47 +00:00
this . currentProfile = this . SettingsManager . GetPreselectedProfile ( Tools . Components . CHAT ) ;
2025-02-15 14:41:12 +00:00
2025-05-24 10:27:00 +00:00
// Get the preselected chat template:
this . currentChatTemplate = this . SettingsManager . GetPreselectedChatTemplate ( Tools . Components . CHAT ) ;
2026-05-25 18:48:26 +00:00
if ( ! this . ComposerState . HasUserDraft & & ! this . ComposerState . HasComposerContent )
this . ComposerState . ApplyTemplate ( this . currentChatTemplate ) ;
2026-09-04 13:48:07 +00:00
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ) ) ;
2026-01-01 15:47:15 +00:00
2026-07-05 12:46:31 +00:00
this . lastAppliedStandardDataSourceOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ( ) ;
2026-08-23 19:15:37 +00:00
var deferredInput = MessageBus . INSTANCE . TakeDeferredMessages < string > ( Event . SEND_TO_CHAT_INPUT ) . LastOrDefault ( ) ;
2026-04-15 17:40:53 +00:00
if ( ! string . IsNullOrWhiteSpace ( deferredInput ) )
2026-05-25 18:48:26 +00:00
this . ComposerState . SetUserInput ( deferredInput ) ;
2026-01-01 15:47:15 +00:00
2025-02-15 14:41:12 +00:00
//
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
//
2026-08-27 14:19:35 +00:00
var deferredRequest = MessageBus . INSTANCE . TakeDeferredMessages < ChatStartRequest > ( Event . SEND_TO_CHAT ) . LastOrDefault ( ) ;
if ( deferredRequest is not null )
2025-01-02 12:16:47 +00:00
{
2025-02-15 14:41:12 +00:00
//
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
2026-08-27 14:19:35 +00:00
this . ChatThread = deferredRequest . ChatThread ;
2026-01-18 19:36:04 +00:00
this . ChatThread . IncludeDateTime = true ;
2026-09-04 13:48:07 +00:00
this . ApplyToolSelectionOfLoadedChat ( ) ;
2026-08-27 14:19:35 +00: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 19:36:04 +00:00
2026-01-18 18:52:19 +00: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 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2025-01-02 12:16:47 +00:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread ) ;
2025-02-15 14:41:12 +00: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 12:16:47 +00:00
if ( this . ChatThread is not null )
{
2025-02-15 14:41:12 +00:00
//
// Check if the chat thread has a name. If not, we
// generate the name now:
//
2025-01-02 12:16:47 +00: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 14:41:12 +00:00
//
// Check if the user wants to apply the standard chat data source options:
//
2026-08-27 14:19:35 +00:00
if ( ! deferredRequest . PreserveDataSourceOptions & &
this . SettingsManager . ConfigurationData . Chat . SendToChatDataSourceBehavior is SendToChatDataSourceBehavior . APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS )
2025-02-15 14:41:12 +00:00
this . ChatThread . DataSourceOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ( ) ;
//
// Check if the user wants to store the chat automatically:
//
2025-01-02 12:16:47 +00:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
this . autoSaveEnabled = true ;
this . mustStoreChat = true ;
2025-02-15 14:41:12 +00:00
//
// When a standard workspace is used, we have to ensure
// that the workspace is available:
//
2025-01-02 12:16:47 +00: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 17:27:23 +00:00
}
2025-02-15 14:41:12 +00:00
else
{
//
// No, the user did not send an assistant result to the chat.
//
this . ApplyStandardDataSourceOptions ( ) ;
}
2025-01-04 17:27:23 +00:00
2025-02-15 14:41:12 +00:00
//
// Check if the user wants to show the latest message after loading:
//
2025-01-04 17:27:23 +00:00
if ( this . SettingsManager . ConfigurationData . Chat . ShowLatestMessageAfterLoading )
{
2025-02-15 14:41:12 +00: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 17:27:23 +00:00
this . mustScrollToBottomAfterRender = true ;
this . scrollRenderCountdown = 4 ;
this . StateHasChanged ( ) ;
2025-01-02 12:16:47 +00:00
}
2025-02-15 14:41:12 +00: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 19:15:37 +00:00
var deferredLoading = MessageBus . INSTANCE . TakeDeferredMessages < LoadChat > ( Event . LOAD_CHAT ) . LastOrDefault ( ) ;
2025-01-02 12:16:47 +00:00
if ( deferredLoading ! = default )
{
this . loadChat = deferredLoading ;
this . mustLoadChat = true ;
2025-01-04 18:27:45 +00:00
this . Logger . LogInformation ( $"The loading of the chat '{this.loadChat.ChatId}' was deferred and will be loaded now." ) ;
2025-01-02 12:16:47 +00:00
}
2025-01-21 18:12:36 +00:00
2025-02-15 14:41:12 +00: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 18:12:36 +00:00
if ( this . ChatThread is not null )
2026-04-15 07:44:12 +00:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ( ) ;
2025-01-02 12:16:47 +00:00
2025-02-15 14:41:12 +00:00
// Select the correct provider:
2025-01-02 12:16:47 +00:00
await this . SelectProviderWhenLoadingChat ( ) ;
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2026-07-15 10:53:30 +00:00
await this . ConsumeMediaOutcomeAsync ( ) ;
2025-01-02 12:16:47 +00:00
await base . OnInitializedAsync ( ) ;
}
2026-07-15 10:53:30 +00: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 16:53:09 +00:00
this . InvokeAsync ( async ( ) = >
2026-07-15 10:53:30 +00:00
{
await this . ConsumeMediaOutcomeAsync ( ) ;
this . StateHasChanged ( ) ;
2026-08-25 16:53:09 +00:00
} ) . Observe ( $"{nameof(ChatComponent)}: consuming a media import outcome" ) ;
2026-07-15 10:53:30 +00: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 12:16:47 +00: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 17:37:18 +00:00
await this . Workspaces . StoreChatAsync ( this . ChatThread ) ;
2025-01-02 12:16:47 +00:00
else
2026-03-05 17:37:18 +00:00
await WorkspaceBehaviour . StoreChatAsync ( this . ChatThread ) ;
2026-04-15 07:44:12 +00:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ( ) ;
2025-01-02 12:16:47 +00:00
}
if ( firstRender & & this . mustLoadChat )
{
2025-01-04 18:27:45 +00:00
this . Logger . LogInformation ( $"Try to load the chat '{this.loadChat.ChatId}' now." ) ;
2025-01-02 12:16:47 +00:00
this . mustLoadChat = false ;
2026-03-05 17:37:18 +00:00
this . ChatThread = await WorkspaceBehaviour . LoadChatAsync ( this . loadChat ) ;
2025-01-02 12:16:47 +00:00
if ( this . ChatThread is not null )
{
2026-05-25 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2025-01-04 18:27:45 +00: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 07:44:12 +00:00
2026-09-04 13:48:07 +00:00
this . ApplyToolSelectionOfLoadedChat ( ) ;
2026-04-15 07:44:12 +00:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ( ) ;
2025-01-02 12:16:47 +00:00
await this . SelectProviderWhenLoadingChat ( ) ;
}
2025-01-04 18:27:45 +00:00
else
this . Logger . LogWarning ( $"The chat '{this.loadChat.ChatId}' could not be loaded." ) ;
2025-01-02 12:16:47 +00:00
this . StateHasChanged ( ) ;
}
if ( this . mustScrollToBottomAfterRender )
{
if ( - - this . scrollRenderCountdown = = 0 )
{
await this . scrollingArea . ScrollToBottom ( ) ;
this . mustScrollToBottomAfterRender = false ;
}
else
{
this . StateHasChanged ( ) ;
}
}
2026-06-23 07:02:40 +00:00
var inputForbidden = this . IsInputForbidden ( ) ;
if ( ! inputForbidden & & this . previousInputForbidden )
await this . inputField . FocusAsync ( ) ;
this . previousInputForbidden = inputForbidden ;
2026-09-13 12:17:25 +00: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 12:16:47 +00:00
await base . OnAfterRenderAsync ( firstRender ) ;
}
2026-03-05 17:37:18 +00:00
protected override async Task OnParametersSetAsync ( )
{
2026-06-23 07:02:40 +00: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 18:48:26 +00:00
await this . ApplyLoadedChatParameterAsync ( ) ;
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2026-07-15 10:53:30 +00:00
await this . ConsumeMediaOutcomeAsync ( ) ;
2026-03-05 17:37:18 +00:00
await base . OnParametersSetAsync ( ) ;
}
2025-01-02 12:16:47 +00:00
#endregion
2026-03-05 17:37:18 +00:00
2026-05-25 18:48:26 +00: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 17:37:18 +00:00
private async Task SyncWorkspaceHeaderWithChatThreadAsync ( )
{
2026-04-15 07:44:12 +00:00
var syncVersion = Interlocked . Increment ( ref this . workspaceHeaderSyncVersion ) ;
var currentChatThread = this . ChatThread ;
if ( currentChatThread is null )
2026-03-05 17:37:18 +00:00
{
2026-04-15 07:44:12 +00:00
this . ClearWorkspaceHeaderState ( ) ;
2026-03-05 17:37:18 +00: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 07:44:12 +00:00
if ( this . currentChatThreadId = = currentChatThread . ChatId
& & this . currentWorkspaceId = = currentChatThread . WorkspaceId )
2026-03-05 17:37:18 +00:00
return ;
2026-04-15 07:44:12 +00: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 17:37:18 +00:00
2026-04-15 07:44:12 +00:00
private void PublishWorkspaceNameIfChanged ( string workspaceName )
{
2026-03-05 17:37:18 +00:00
// Only notify the parent when the name actually changed to prevent
2026-04-15 07:44:12 +00: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 17:37:18 +00:00
}
2026-04-15 07:44:12 +00:00
2026-06-06 08:06:41 +00: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 11:50:42 +00: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 12:16:47 +00:00
private bool IsProviderSelected = > this . Provider . UsedLLMProvider ! = LLMProviders . NONE ;
2026-05-24 11:50:42 +00:00
private bool IsCurrentChatStreaming = > this . ChatThread is not null & & this . AIJobService . IsChatGenerationActive ( this . ChatThread . ChatId ) ;
2025-01-02 12:16:47 +00:00
2025-04-27 07:06:05 +00: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 12:16:47 +00:00
2025-07-11 07:57:46 +00:00
private bool CanThreadBeSaved = > this . ChatThread is not null & & this . ChatThread . Blocks . Any ( b = > ! b . HideFromUser ) ;
2025-01-02 12:16:47 +00:00
2025-05-17 12:22:10 +00:00
private string TooltipAddChatToWorkspace = > string . Format ( T ( "Start new chat in workspace '{0}'" ) , this . currentWorkspaceName ) ;
2025-01-02 12:16:47 +00:00
2026-06-21 09:52:02 +00:00
private string UserInputStyle = > this . SettingsManager . ConfigurationData . Confidence . ShowProviderConfidence ? this . Provider . UsedLLMProvider . GetConfidence ( this . SettingsManager ) . SetColorStyle ( this . SettingsManager ) : string . Empty ;
2026-09-13 12:17:25 +00: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 12:16:47 +00:00
2025-02-15 14:41:12 +00:00
private void ApplyStandardDataSourceOptions ( )
{
var chatDefaultOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ( ) ;
2026-07-05 12:46:31 +00:00
this . lastAppliedStandardDataSourceOptions = chatDefaultOptions . CreateCopy ( ) ;
2025-02-15 14:41:12 +00:00
this . earlyDataSourceOptions = chatDefaultOptions ;
2025-03-08 13:23:24 +00:00
if ( this . ChatThread is not null )
this . ChatThread . DataSourceOptions = chatDefaultOptions ;
2025-02-15 14:41:12 +00:00
this . dataSourceSelectionComponent ? . ChangeOptionWithoutSaving ( chatDefaultOptions ) ;
}
2026-07-05 12:46:31 +00:00
private async Task ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange ( )
{
var updatedStandardOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ( ) ;
var previousStandardOptions = this . lastAppliedStandardDataSourceOptions ;
this . lastAppliedStandardDataSourceOptions = updatedStandardOptions . CreateCopy ( ) ;
if ( this . ChatThread is null )
{
this . earlyDataSourceOptions = updatedStandardOptions ;
this . dataSourceSelectionComponent ? . ChangeOptionWithoutSaving ( updatedStandardOptions ) ;
return ;
}
if ( ! DataSourceOptionsAreEqual ( this . ChatThread . DataSourceOptions , previousStandardOptions ) )
return ;
await this . SetCurrentDataSourceOptions ( updatedStandardOptions ) ;
this . dataSourceSelectionComponent ? . ChangeOptionWithoutSaving ( updatedStandardOptions , this . ChatThread . AISelectedDataSources ) ;
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 14:41:12 +00:00
2025-01-02 12:16:47 +00: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 14:25:51 +00: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 12:16:47 +00: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 19:01:27 +00:00
this . currentProfile = this . SettingsManager . GetProfileById ( profile . Id ) ;
2025-01-02 12:16:47 +00:00
2026-09-13 12:17:25 +00: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 12:16:47 +00:00
{
2026-09-13 12:17:25 +00:00
this . ChatThread = this . ChatThread with
{
SelectedProfile = this . currentProfile . Id ,
} ;
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread ) ;
}
2025-01-02 12:16:47 +00:00
}
2025-05-24 10:27:00 +00:00
private async Task ChatTemplateWasChanged ( ChatTemplate chatTemplate )
{
2026-06-10 19:01:27 +00:00
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( chatTemplate . Id ) ;
2025-08-18 18:40:52 +00:00
if ( ! string . IsNullOrWhiteSpace ( this . currentChatTemplate . PredefinedUserPrompt ) )
2026-05-25 18:48:26 +00:00
this . ComposerState . SetSystemInput ( this . currentChatTemplate . PredefinedUserPrompt ) ;
2026-01-01 15:47:15 +00:00
// Apply template's file attachments (replaces existing):
2026-05-25 18:48:26 +00:00
this . ComposerState . ReplaceFileAttachments ( this . currentChatTemplate . FileAttachments ) ;
2026-01-01 15:47:15 +00:00
2026-09-13 12:17:25 +00:00
if ( this . ChatThread is not null )
await this . StartNewChat ( true ) ;
2025-05-24 10:27:00 +00:00
}
2025-01-02 12:16:47 +00:00
2026-06-10 19:01:27 +00:00
private void RefreshCurrentProfileAndChatTemplate ( )
{
this . currentProfile = this . SettingsManager . GetProfileById ( this . currentProfile . Id ) ;
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( this . currentChatTemplate . Id ) ;
}
2026-06-21 13:59:23 +00:00
private async Task RefreshChatSelectionsAfterConfigurationChange ( )
{
var previousProvider = this . Provider ;
var previousChatTemplate = this . currentChatTemplate ;
2026-09-04 13:48:07 +00:00
this . Provider = this . SettingsManager . GetChatProviderForLoadedChat ( this . Provider . Id ) ;
2026-06-21 13:59:23 +00: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 12:46:31 +00:00
await this . ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange ( ) ;
2026-06-21 13:59:23 +00:00
}
2025-02-17 11:33:34 +00:00
private IReadOnlyList < DataSourceAgentSelected > GetAgentSelectedDataSources ( )
{
if ( this . ChatThread is null )
return [ ] ;
return this . ChatThread . AISelectedDataSources ;
}
2025-02-15 14:41:12 +00: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 19:13:08 +00:00
private bool IsInputForbidden ( )
{
if ( ! this . IsProviderSelected )
return true ;
2026-05-24 11:50:42 +00:00
if ( this . IsCurrentChatStreaming )
2025-03-08 19:13:08 +00:00
return true ;
if ( ! this . ChatThread . IsLLMProviderAllowed ( this . Provider ) )
return true ;
return false ;
}
2025-01-02 12:16:47 +00:00
private async Task InputKeyEvent ( KeyboardEventArgs keyEvent )
{
2025-02-15 14:41:12 +00:00
if ( this . dataSourceSelectionComponent ? . IsVisible ? ? false )
this . dataSourceSelectionComponent . Hide ( ) ;
2025-01-02 12:16:47 +00:00
this . hasUnsavedChanges = true ;
2026-05-25 18:48:26 +00:00
this . ComposerState . MarkUserDraft ( ) ;
2025-01-02 12:16:47 +00: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 12:17:25 +00:00
2025-01-02 12:16:47 +00: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 11:40:40 +00:00
private async Task ApplyMarkdownFormat ( string formatType )
{
if ( this . IsInputForbidden ( ) )
return ;
if ( this . dataSourceSelectionComponent ? . IsVisible ? ? false )
this . dataSourceSelectionComponent . Hide ( ) ;
2026-05-25 18:48:26 +00: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 11:40:40 +00:00
this . hasUnsavedChanges = true ;
}
2026-07-15 10:53:30 +00: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 12:17:25 +00:00
var threadName = string . IsNullOrWhiteSpace ( this . ComposerState . UserInput )
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName ;
this . ChatThread = this . NewChatThread ( threadName ) with
2026-07-15 10:53:30 +00: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 12:16:47 +00:00
2025-01-03 20:18:27 +00:00
private async Task SendMessage ( bool reuseLastUserPrompt = false )
2025-01-02 12:16:47 +00:00
{
2026-07-15 10:53:30 +00:00
if ( this . MediaTranscriptionService . IsBusy ( this . CurrentMediaImportOwner ) )
return ;
2026-09-04 13:48:07 +00:00
await this . RefreshProviderSelectionFromConfigurationAsync ( ) ;
2025-01-02 12:16:47 +00:00
if ( ! this . IsProviderSelected )
return ;
2025-03-08 19:13:08 +00:00
if ( ! this . ChatThread . IsLLMProviderAllowed ( this . Provider ) )
return ;
2026-06-10 19:01:27 +00:00
this . RefreshCurrentProfileAndChatTemplate ( ) ;
2026-03-05 17:37:18 +00:00
// Blur the focus away from the input field to be able to clear it:
2025-01-02 12:16:47 +00:00
await this . inputField . BlurAsync ( ) ;
// Create a new chat thread if necessary:
if ( this . ChatThread is null )
{
2026-09-13 12:17:25 +00:00
this . ChatThread = this . NewChatThread ( this . ExtractThreadName ( this . ComposerState . UserInput ) ) with
2025-01-02 12:16:47 +00:00
{
2025-02-15 14:41:12 +00:00
DataSourceOptions = this . earlyDataSourceOptions ,
2025-01-02 12:16:47 +00:00
} ;
2026-09-13 12:17:25 +00:00
2026-05-25 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2025-01-02 12:16:47 +00: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 18:48:26 +00:00
this . ChatThread . Name = this . ExtractThreadName ( this . ComposerState . UserInput ) ;
2025-01-02 13:50:54 +00:00
2025-05-24 10:27:00 +00:00
// Update provider, profile and chat template:
2025-01-02 13:50:54 +00:00
this . ChatThread . SelectedProvider = this . Provider . Id ;
this . ChatThread . SelectedProfile = this . currentProfile . Id ;
2025-05-24 17:11:28 +00: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 12:16:47 +00:00
}
2025-01-03 20:18:27 +00:00
2025-01-02 12:16:47 +00:00
var time = DateTimeOffset . Now ;
2025-02-10 17:49:22 +00:00
IContent ? lastUserPrompt ;
2025-01-03 20:18:27 +00:00
if ( ! reuseLastUserPrompt )
2025-01-02 12:16:47 +00:00
{
2026-05-25 18:48:26 +00:00
var normalizedAttachments = this . ComposerState . FileAttachments
2026-04-09 14:01:47 +00:00
. Select ( attachment = > attachment . Normalize ( ) )
. Where ( attachment = > attachment . IsValid )
. ToList ( ) ;
2025-02-10 17:49:22 +00:00
lastUserPrompt = new ContentText
{
2026-05-25 18:48:26 +00:00
Text = this . ComposerState . UserInput ,
2026-04-09 14:01:47 +00:00
FileAttachments = normalizedAttachments ,
2025-02-10 17:49:22 +00:00
} ;
2026-07-15 10:53:30 +00:00
this . ChatThread . PendingMediaTranscripts . Clear ( ) ;
2025-02-10 17:49:22 +00:00
2025-01-03 20:18:27 +00:00
//
// Add the user message to the thread:
//
this . ChatThread ? . Blocks . Add ( new ContentBlock
2025-01-02 12:16:47 +00:00
{
2025-01-03 20:18:27 +00:00
Time = time ,
ContentType = ContentType . TEXT ,
Role = ChatRole . USER ,
2025-02-10 17:49:22 +00:00
Content = lastUserPrompt ,
2025-01-03 20:18:27 +00:00
} ) ;
2025-01-02 12:16:47 +00:00
2025-01-03 20:18:27 +00: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 12:16:47 +00:00
}
2025-02-10 17:49:22 +00:00
else
lastUserPrompt = this . ChatThread . Blocks . Last ( x = > x . Role is ChatRole . USER ) . Content ;
2025-01-02 12:16:47 +00: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 12:01:10 +00:00
await this . inputField . FocusAsync ( ) ;
2026-03-05 17:37:18 +00:00
2026-05-25 18:48:26 +00:00
this . ComposerState . Clear ( ) ;
2026-03-05 17:37:18 +00:00
2025-07-02 12:01:10 +00:00
await this . inputField . BlurAsync ( ) ;
2026-09-13 12:17:25 +00:00
2025-01-02 12:16:47 +00: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 13:48:07 +00: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 11:50:42 +00:00
await this . AIJobService . TryStartChatGenerationAsync ( new ChatGenerationRequest
2025-01-03 22:13:56 +00:00
{
2026-09-04 13:48:07 +00:00
ChatThread = this . ChatThread ,
2026-05-24 11:50:42 +00:00
AIText = aiText ,
LastUserPrompt = lastUserPrompt ,
ProviderSettings = this . Provider ,
IsForeground = true ,
} ) ;
await this . SyncForegroundChatAsync ( ) ;
2025-01-02 12:16:47 +00:00
this . StateHasChanged ( ) ;
}
2025-01-03 22:13:56 +00:00
private async Task CancelStreaming ( )
{
2026-05-24 11:50:42 +00:00
if ( this . ChatThread is not null )
await this . AIJobService . CancelChatGenerationAsync ( this . ChatThread . ChatId ) ;
2025-01-03 22:13:56 +00:00
}
2026-09-04 13:48:07 +00: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 12:17:25 +00:00
private void SelectedToolIdsChanged ( HashSet < string > updatedToolIds )
2026-09-04 13:48:07 +00: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 22:13:56 +00:00
2025-01-02 12:16:47 +00: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 17:37:18 +00:00
await this . Workspaces . StoreChatAsync ( this . ChatThread ) ;
2025-01-02 12:16:47 +00:00
else
2026-03-05 17:37:18 +00:00
await WorkspaceBehaviour . StoreChatAsync ( this . ChatThread ) ;
2025-01-02 12:16:47 +00:00
this . hasUnsavedChanges = false ;
}
private async Task StartNewChat ( bool useSameWorkspace = false , bool deletePreviousChat = false )
{
2025-02-15 14:41:12 +00: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 11:50:42 +00:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_MANUALLY & & this . hasUnsavedChanges & & ! this . IsCurrentChatStreaming )
2025-01-02 12:16:47 +00:00
{
2025-08-28 16:51:44 +00:00
var dialogParameters = new DialogParameters < ConfirmDialog >
2025-01-02 12:16:47 +00:00
{
2025-08-28 16:51:44 +00:00
{ x = > x . Message , "Are you sure you want to start a new chat? All unsaved changes will be lost." } ,
2025-01-02 12:16:47 +00:00
} ;
var dialogReference = await this . DialogService . ShowAsync < ConfirmDialog > ( "Delete Chat" , dialogParameters , DialogOptions . FULLSCREEN ) ;
var dialogResult = await dialogReference . Result ;
if ( dialogResult is null | | dialogResult . Canceled )
return ;
}
2025-02-15 14:41:12 +00:00
//
// Delete the previous chat when desired and necessary:
//
2025-01-02 12:16:47 +00:00
if ( this . ChatThread is not null & & deletePreviousChat )
{
string chatPath ;
if ( this . ChatThread . WorkspaceId = = Guid . Empty )
chatPath = Path . Join ( SettingsManager . DataDirectory , "tempChats" , this . ChatThread . ChatId . ToString ( ) ) ;
else
chatPath = Path . Join ( SettingsManager . DataDirectory , "workspaces" , this . ChatThread . WorkspaceId . ToString ( ) , this . ChatThread . ChatId . ToString ( ) ) ;
if ( this . Workspaces is null )
2026-03-05 17:37:18 +00:00
await WorkspaceBehaviour . DeleteChatAsync ( this . DialogService , this . ChatThread . WorkspaceId , this . ChatThread . ChatId , askForConfirmation : false ) ;
2025-01-02 12:16:47 +00:00
else
2026-03-05 17:37:18 +00:00
await this . Workspaces . DeleteChatAsync ( chatPath , askForConfirmation : false , unloadChat : true ) ;
2025-01-02 12:16:47 +00:00
}
2025-02-15 14:41:12 +00:00
//
// Reset our state:
//
2025-01-02 12:16:47 +00:00
this . hasUnsavedChanges = false ;
2026-05-25 18:48:26 +00:00
this . ComposerState . Clear ( ) ;
2026-09-04 13:48:07 +00:00
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ) ) ;
2026-06-10 19:01:27 +00:00
this . RefreshCurrentProfileAndChatTemplate ( ) ;
2025-01-02 12:16:47 +00:00
2025-02-15 14:41:12 +00:00
//
// Reset the LLM provider considering the user's settings:
//
2025-01-02 12:16:47 +00: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 08:59:56 +00:00
if ( this . Provider = = AIStudio . Settings . Provider . NONE )
2025-01-02 12:16:47 +00:00
{
this . Provider = this . SettingsManager . GetPreselectedProvider ( Tools . Components . CHAT ) ;
await this . ProviderChanged . InvokeAsync ( this . Provider ) ;
}
break ;
}
2025-02-15 14:41:12 +00:00
//
// Reset the chat thread or create a new one:
//
2025-01-02 12:16:47 +00:00
if ( ! useSameWorkspace )
{
2025-02-15 14:41:12 +00: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 12:16:47 +00:00
this . ChatThread = null ;
2026-04-15 07:44:12 +00:00
this . ClearWorkspaceHeaderState ( ) ;
2025-01-02 12:16:47 +00:00
}
else
{
2025-02-15 14:41:12 +00: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 12:17:25 +00:00
this . ChatThread = this . NewChatThread ( string . Empty ) ;
2025-01-02 12:16:47 +00:00
}
2026-01-01 15:47:15 +00:00
2026-05-25 18:48:26 +00:00
this . ComposerState . ApplyTemplate ( this . currentChatTemplate ) ;
2026-01-01 15:47:15 +00:00
2025-02-15 14:41:12 +00:00
// Now, we have to reset the data source options as well:
this . ApplyStandardDataSourceOptions ( ) ;
// Notify the parent component about the change:
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2026-05-25 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2025-01-02 12:16:47 +00:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread ) ;
}
2026-09-13 12:17:25 +00:00
2025-01-02 12:16:47 +00:00
private async Task MoveChatToWorkspace ( )
{
if ( this . ChatThread is null )
return ;
2026-05-24 11:50:42 +00:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_MANUALLY & & this . hasUnsavedChanges & & ! this . IsCurrentChatStreaming )
2025-01-02 12:16:47 +00:00
{
2025-08-28 16:51:44 +00:00
var confirmationDialogParameters = new DialogParameters < ConfirmDialog >
2025-01-02 12:16:47 +00:00
{
2025-08-28 16:51:44 +00:00
{ x = > x . Message , T ( "Are you sure you want to move this chat? All unsaved changes will be lost." ) } ,
2025-01-02 12:16:47 +00:00
} ;
var confirmationDialogReference = await this . DialogService . ShowAsync < ConfirmDialog > ( "Unsaved Changes" , confirmationDialogParameters , DialogOptions . FULLSCREEN ) ;
var confirmationDialogResult = await confirmationDialogReference . Result ;
if ( confirmationDialogResult is null | | confirmationDialogResult . Canceled )
return ;
}
2025-08-28 16:51:44 +00:00
var dialogParameters = new DialogParameters < WorkspaceSelectionDialog >
2025-01-02 12:16:47 +00:00
{
2025-08-28 16:51:44 +00:00
{ x = > x . Message , T ( "Please select the workspace where you want to move the chat to." ) } ,
2025-08-31 12:45:03 +00:00
{ x = > x . SelectedWorkspace , this . ChatThread ? . WorkspaceId ? ? Guid . Empty } ,
2025-08-28 16:51:44 +00:00
{ x = > x . ConfirmText , T ( "Move chat" ) } ,
2025-01-02 12:16:47 +00:00
} ;
2026-06-06 08:06:41 +00:00
var dialogReference = await this . DialogService . ShowAsync < WorkspaceSelectionDialog > ( T ( "Move Chat to Workspace" ) , dialogParameters , DialogOptions . FULLSCREEN_MANUAL_ESCAPE ) ;
2025-01-02 12:16:47 +00: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 10:53:30 +00:00
await WorkspaceBehaviour . MoveChatAsync ( this . ChatThread ! , workspaceId ) ;
2026-05-25 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2026-04-15 07:44:12 +00:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ( ) ;
2025-01-02 12:16:47 +00:00
}
2026-05-25 18:48:26 +00:00
private async Task LoadedChatChanged ( bool notifyParent = true )
2025-01-02 12:16:47 +00:00
{
this . hasUnsavedChanges = false ;
2026-05-25 18:48:26 +00:00
this . ComposerState . Clear ( ) ;
2025-01-21 14:36:22 +00:00
if ( this . ChatThread is not null )
{
2026-05-24 11:50:42 +00:00
this . ChatThread = this . AIJobService . TryGetLiveChatThread ( this . ChatThread . ChatId ) ? ? this . ChatThread ;
2026-05-25 18:48:26 +00:00
this . loadedParameterChatId = this . ChatThread . ChatId ;
this . loadedParameterWorkspaceId = this . ChatThread . WorkspaceId ;
if ( notifyParent )
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread ) ;
2026-04-15 07:44:12 +00:00
await this . SyncWorkspaceHeaderWithChatThreadAsync ( ) ;
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2025-02-17 11:33:34 +00:00
this . dataSourceSelectionComponent ? . ChangeOptionWithoutSaving ( this . ChatThread . DataSourceOptions , this . ChatThread . AISelectedDataSources ) ;
2026-09-04 13:48:07 +00:00
this . ApplyToolSelectionOfLoadedChat ( ) ;
2025-01-21 14:36:22 +00:00
}
else
{
2026-05-25 18:48:26 +00:00
this . loadedParameterChatId = Guid . Empty ;
this . loadedParameterWorkspaceId = Guid . Empty ;
2026-04-15 07:44:12 +00:00
this . ClearWorkspaceHeaderState ( ) ;
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2025-02-15 14:41:12 +00:00
this . ApplyStandardDataSourceOptions ( ) ;
2025-01-21 14:36:22 +00:00
}
2025-01-21 18:12:36 +00:00
2025-01-02 12:16:47 +00:00
await this . SelectProviderWhenLoadingChat ( ) ;
if ( this . SettingsManager . ConfigurationData . Chat . ShowLatestMessageAfterLoading )
{
this . mustScrollToBottomAfterRender = true ;
this . scrollRenderCountdown = 2 ;
}
2025-01-21 18:12:36 +00:00
this . StateHasChanged ( ) ;
2025-01-02 12:16:47 +00:00
}
2026-09-04 13:48:07 +00: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 12:16:47 +00:00
private async Task ResetState ( )
{
this . hasUnsavedChanges = false ;
2026-05-25 18:48:26 +00:00
this . ComposerState . Clear ( ) ;
2026-04-15 07:44:12 +00:00
this . ClearWorkspaceHeaderState ( ) ;
2025-01-02 12:16:47 +00:00
this . ChatThread = null ;
2026-05-25 18:48:26 +00:00
this . MarkCurrentChatAsLoadedParameter ( ) ;
2026-05-24 11:50:42 +00:00
await this . SyncForegroundChatAsync ( ) ;
2025-02-15 14:41:12 +00:00
this . ApplyStandardDataSourceOptions ( ) ;
2025-01-02 12:16:47 +00:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread ) ;
}
2026-09-13 12:17:25 +00:00
2025-01-02 12:16:47 +00:00
private async Task SelectProviderWhenLoadingChat ( )
{
var chatProvider = this . ChatThread ? . SelectedProvider ;
2025-01-02 13:50:54 +00:00
var chatProfile = this . ChatThread ? . SelectedProfile ;
2025-05-24 10:27:00 +00:00
var chatChatTemplate = this . ChatThread ? . SelectedChatTemplate ;
2025-01-04 18:27:45 +00:00
2026-04-16 07:09:05 +00:00
this . Provider = this . SettingsManager . GetChatProviderForLoadedChat ( chatProvider ) ;
2025-01-02 12:16:47 +00:00
await this . ProviderChanged . InvokeAsync ( this . Provider ) ;
2025-01-02 13:50:54 +00:00
// Try to select the profile:
if ( ! string . IsNullOrWhiteSpace ( chatProfile ) )
2026-06-10 19:01:27 +00:00
this . currentProfile = this . SettingsManager . GetProfileById ( chatProfile ) ;
2025-05-24 10:27:00 +00:00
// Try to select the chat template:
if ( ! string . IsNullOrWhiteSpace ( chatChatTemplate ) )
2026-06-10 19:01:27 +00:00
this . currentChatTemplate = this . SettingsManager . GetChatTemplateById ( chatChatTemplate ) ;
2025-01-02 12:16:47 +00:00
}
private async Task ToggleWorkspaceOverlay ( )
{
await MessageBus . INSTANCE . SendMessage < bool > ( this , Event . WORKSPACE_TOGGLE_OVERLAY ) ;
}
2025-01-03 17:01:22 +00: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 20:18:27 +00:00
private async Task RegenerateBlock ( IContent aiBlock )
{
if ( this . ChatThread is null )
return ;
2025-03-08 19:13:08 +00:00
if ( ! this . ChatThread . IsLLMProviderAllowed ( this . Provider ) )
return ;
2025-01-03 20:18:27 +00:00
this . ChatThread . Remove ( aiBlock , removeForRegenerate : true ) ;
this . hasUnsavedChanges = true ;
this . StateHasChanged ( ) ;
await this . SendMessage ( reuseLastUserPrompt : true ) ;
}
2025-01-03 17:01:22 +00:00
2025-01-03 20:58:31 +00:00
private Task EditLastUserBlock ( IContent block )
{
if ( this . ChatThread is null )
return Task . CompletedTask ;
2026-09-13 12:17:25 +00:00
2025-01-03 20:58:31 +00:00
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
2026-09-13 12:17:25 +00:00
2025-01-03 20:58:31 +00:00
var lastBlock = this . ChatThread . Blocks . Last ( ) ;
var lastBlockContent = lastBlock . Content ;
if ( lastBlockContent is null )
return Task . CompletedTask ;
2026-09-13 12:17:25 +00:00
2026-05-22 14:18:36 +00:00
this . RestoreComposerFromTextBlock ( textBlock ) ;
2025-01-03 20:58:31 +00:00
this . ChatThread . Remove ( block ) ;
this . ChatThread . Remove ( lastBlockContent ) ;
this . hasUnsavedChanges = true ;
this . StateHasChanged ( ) ;
return Task . CompletedTask ;
}
2026-09-13 12:17:25 +00:00
2025-01-03 20:58:31 +00:00
private Task EditLastBlock ( IContent block )
{
if ( this . ChatThread is null )
return Task . CompletedTask ;
2026-09-13 12:17:25 +00:00
2025-01-03 20:58:31 +00:00
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
2026-09-13 12:17:25 +00:00
2026-05-22 14:18:36 +00:00
this . RestoreComposerFromTextBlock ( textBlock ) ;
2025-01-03 20:58:31 +00:00
this . ChatThread . Remove ( block ) ;
this . hasUnsavedChanges = true ;
this . StateHasChanged ( ) ;
return Task . CompletedTask ;
}
2026-05-22 14:18:36 +00:00
private void RestoreComposerFromTextBlock ( ContentText textBlock )
{
2026-05-25 18:48:26 +00:00
this . ComposerState . RestoreFromTextBlock ( textBlock ) ;
2026-05-22 14:18:36 +00:00
}
2025-01-03 20:58:31 +00:00
2026-09-13 12:17:25 +00: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 16:43:37 +00:00
{
2026-09-13 12:17:25 +00:00
var provider = AIStudio . Settings . Provider . NONE ;
var parts = ConversationParts . NOTHING ;
2026-09-09 16:43:37 +00:00
//
2026-09-13 12:17:25 +00: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 16:43:37 +00:00
//
2026-09-13 12:17:25 +00:00
await this . InvokeAsync ( ( ) = >
2026-09-09 16:43:37 +00:00
{
2026-09-13 12:17:25 +00: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 ) ;
provider = this . Provider ;
parts = ConversationParts . Of ( thread , this . BuildSystemPromptFor ( thread ) , this . UserInput , this . ComposerState . FileAttachments , provider . SupportsImageInput ( ) ) ;
} ) ;
2026-09-09 16:43:37 +00:00
2026-09-13 12:17:25 +00:00
var counted = await this . ConversationTokenCounter . CountAsync ( provider , parts , token ) ;
if ( token . IsCancellationRequested )
2026-09-09 16:43:37 +00:00
return ;
2026-09-13 12:17:25 +00:00
await this . InvokeAsync ( ( ) = >
2026-09-09 16:43:37 +00:00
{
2026-09-13 12:17:25 +00:00
if ( counted = = this . conversationTokens )
return ;
this . conversationTokens = counted ;
this . StateHasChanged ( ) ;
} ) ;
2026-09-09 16:43:37 +00:00
}
2026-09-13 12:17:25 +00: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.
///
/// The tools are 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.
/// </remarks>
/// <param name="thread">The thread to build the prompt for.</param>
/// <returns>The system prompt as it would be sent.</returns>
private string BuildSystemPromptFor ( ChatThread thread )
{
var toolDefinitions = this . ToolRegistry . FilterToolIdsForProvider ( this . Provider , this . selectedToolIds )
. Select ( this . ToolRegistry . GetDefinition )
. Where ( definition = > definition is not null )
. Select ( definition = > definition ! )
. ToList ( ) ;
return thread . BuildSystemPrompt ( this . SettingsManager , toolDefinitions ) . Text ;
}
/// <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 16:43:37 +00:00
2025-01-02 12:16:47 +00:00
#region Overrides of MSGComponentBase
2025-04-24 11:50:14 +00:00
2025-04-12 19:13:33 +00:00
protected override async Task ProcessIncomingMessage < T > ( ComponentBase ? sendingComponent , Event triggeredEvent , T ? data ) where T : default
2025-01-02 12:16:47 +00:00
{
switch ( triggeredEvent )
{
case Event . RESET_CHAT_STATE :
await this . ResetState ( ) ;
break ;
case Event . CHAT_STREAMING_DONE :
2026-02-08 16:27:33 +00: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 12:16:47 +00:00
if ( this . autoSaveEnabled )
await this . SaveThread ( ) ;
break ;
2026-06-06 08:06:41 +00:00
case Event . WORKSPACE_RENAMED :
if ( data is Guid workspaceId )
await this . RefreshRenamedWorkspaceHeaderAsync ( workspaceId ) ;
break ;
2026-06-10 19:01:27 +00:00
case Event . CONFIGURATION_CHANGED :
2026-06-21 13:59:23 +00:00
case Event . PLUGINS_RELOADED :
2026-09-13 12:17:25 +00:00
await this . RefreshCulture ( ) ;
2026-06-21 13:59:23 +00:00
await this . RefreshChatSelectionsAfterConfigurationChange ( ) ;
2026-06-10 19:01:27 +00:00
this . StateHasChanged ( ) ;
break ;
2025-01-02 12:16:47 +00:00
2026-05-24 11:50:42 +00: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 07:02:40 +00:00
{
2026-05-24 11:50:42 +00:00
this . hasUnsavedChanges = false ;
2026-06-23 07:02:40 +00:00
this . previousInputForbidden = true ;
}
2026-05-24 11:50:42 +00:00
this . StateHasChanged ( ) ;
}
break ;
2026-09-04 13:48:07 +00:00
2025-01-02 12:16:47 +00:00
}
}
2025-04-12 19:13:33 +00:00
protected override Task < TResult ? > ProcessIncomingMessageWithResult < TPayload , TResult > ( ComponentBase ? sendingComponent , Event triggeredEvent , TPayload ? data ) where TResult : default where TPayload : default
2025-01-02 12:16:47 +00: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 11:50:42 +00:00
if ( this . IsCurrentChatStreaming )
return Task . FromResult ( ( TResult ? ) ( object ) false ) ;
2025-01-02 12:16:47 +00:00
2026-05-25 18:48:26 +00:00
return Task . FromResult ( ( TResult ? ) ( object ) ( this . hasUnsavedChanges | | this . ComposerState . HasVisibleUserDraft ) ) ;
2025-01-02 12:16:47 +00:00
}
return Task . FromResult ( default ( TResult ) ) ;
}
#endregion
2026-08-23 19:15:37 +00:00
#region Overrides of MSGComponentBase
2025-01-02 12:16:47 +00:00
2026-08-23 19:15:37 +00:00
protected override async ValueTask DisposeResourcesAsync ( )
2025-01-02 12:16:47 +00:00
{
2026-07-15 10:53:30 +00:00
this . MediaTranscriptionService . StateChanged - = this . OnMediaImportStateChanged ;
2026-09-13 12:17:25 +00:00
if ( this . tokenTracker is not null )
await this . tokenTracker . DisposeAsync ( ) ;
2025-01-02 12:16:47 +00:00
if ( this . SettingsManager . ConfigurationData . Workspace . StorageBehavior is WorkspaceStorageBehavior . STORE_CHATS_AUTOMATICALLY )
{
await this . SaveThread ( ) ;
this . hasUnsavedChanges = false ;
}
2025-01-03 22:13:56 +00:00
2026-05-24 11:50:42 +00:00
await this . AIJobService . SetForegroundAsync ( AIJobKind . CHAT_GENERATION , this . foregroundChatId , false ) ;
2025-01-02 12:16:47 +00:00
}
#endregion
2026-09-04 13:48:07 +00:00
}