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-03-14 12:40:40 +01:00
2026-09-09 18:43:37 +02:00
[Inject]
private RustService RustService { 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-07-05 14:46:31 +02:00
private DataSourceOptions lastAppliedStandardDataSourceOptions = 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-09 18:43:37 +02:00
private string tokenCount = "0" ;
private bool HasCustomTokenizer => ! string . IsNullOrWhiteSpace ( this . Provider . TokenizerPath );
private string TokenCountMessage => this . HasCustomTokenizer
? $"{this.T(" Estimated amount of tokens : ")} {this.tokenCount}"
: string . Empty ;
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-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-09-04 15:48:07 +02:00
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ));
2026-01-01 16:47:15 +01:00
2026-07-05 14:46:31 +02:00
this . lastAppliedStandardDataSourceOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ();
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.
//
this . ApplyStandardDataSourceOptions ();
}
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 ;
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 ;
2026-09-09 18:43:37 +02:00
var providerChanged = this . Provider != this . lastSeenProvider ;
2026-06-23 09:02:40 +02:00
if ( incomingChatId != this . lastSeenChatId || this . Provider != this . lastSeenProvider )
{
this . lastSeenChatId = incomingChatId ;
this . lastSeenProvider = this . Provider ;
2026-09-09 18:43:37 +02:00
if ( providerChanged )
this . tokenCount = "0" ;
2026-06-23 09:02:40 +02:00
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-09-09 18:43:37 +02:00
if ( providerChanged && this . HasCustomTokenizer )
await this . CalculateTokenCount ();
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 ;
private string UserInputClass => this . SettingsManager . ConfigurationData . Confidence . ShowProviderConfidence ? "confidence-border" : string . Empty ;
2025-01-02 13:16:47 +01:00
2025-02-15 15:41:12 +01:00
private void ApplyStandardDataSourceOptions ()
{
var chatDefaultOptions = this . SettingsManager . ConfigurationData . Chat . PreselectedDataSourceOptions . CreateCopy ();
2026-07-05 14:46:31 +02:00
this . lastAppliedStandardDataSourceOptions = chatDefaultOptions . CreateCopy ();
2025-02-15 15:41:12 +01:00
this . earlyDataSourceOptions = chatDefaultOptions ;
2025-03-08 14:23:24 +01:00
if ( this . ChatThread is not null )
this . ChatThread . DataSourceOptions = chatDefaultOptions ;
2025-02-15 15:41:12 +01:00
this . dataSourceSelectionComponent ?. ChangeOptionWithoutSaving ( chatDefaultOptions );
}
2026-07-05 14:46:31 +02: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 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
if ( this . ChatThread is null )
return ;
this . ChatThread = this . ChatThread with
{
2025-01-02 14:50:54 +01:00
SelectedProfile = this . currentProfile . Id ,
2025-01-02 13:16:47 +01:00
};
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
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
2025-05-24 12:27:00 +02:00
if ( this . ChatThread is null )
return ;
2025-05-24 19:11:28 +02:00
await this . StartNewChat ( true );
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
await this . ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange ();
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-09 18:43:37 +02:00
if ( isEnter )
await this . CalculateTokenCount ();
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 );
this . ChatThread = new ()
{
IncludeDateTime = true ,
SelectedProvider = this . Provider . Id ,
SelectedProfile = this . currentProfile . Id ,
SelectedChatTemplate = this . currentChatTemplate . Id ,
2026-09-04 15:48:07 +02:00
SelectedToolIds = [.. this . selectedToolIds ],
2026-07-15 12:53:30 +02:00
SystemPrompt = SystemPrompts . DEFAULT ,
WorkspaceId = this . currentWorkspaceId ,
ChatId = Guid . NewGuid (),
DataSourceOptions = this . earlyDataSourceOptions ,
Name = string . IsNullOrWhiteSpace ( this . ComposerState . UserInput )
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName ,
Blocks = this . currentChatTemplate == ChatTemplate . NO_CHAT_TEMPLATE ? [] : this . currentChatTemplate . ExampleConversation . Select ( block => block . DeepClone ()). ToList (),
};
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 )
{
this . ChatThread = new ()
{
2026-01-18 20:36:04 +01:00
IncludeDateTime = true ,
2025-01-02 13:16:47 +01:00
SelectedProvider = this . Provider . Id ,
2025-01-02 14:50:54 +01:00
SelectedProfile = this . currentProfile . Id ,
2025-05-24 12:27:00 +02:00
SelectedChatTemplate = this . currentChatTemplate . Id ,
2026-09-04 15:48:07 +02:00
SelectedToolIds = [.. this . selectedToolIds ],
2025-01-02 14:50:54 +01:00
SystemPrompt = SystemPrompts . DEFAULT ,
2025-01-02 13:16:47 +01:00
WorkspaceId = this . currentWorkspaceId ,
ChatId = Guid . NewGuid (),
2025-02-15 15:41:12 +01:00
DataSourceOptions = this . earlyDataSourceOptions ,
2026-05-25 20:48:26 +02:00
Name = this . ExtractThreadName ( this . ComposerState . UserInput ),
2025-08-18 20:40:52 +02:00
Blocks = this . currentChatTemplate == ChatTemplate . NO_CHAT_TEMPLATE ? [] : this . currentChatTemplate . ExampleConversation . Select ( x => x . DeepClone ()). ToList (),
2025-01-02 13:16:47 +01: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
lastUserPrompt = this . ChatThread . Blocks . Last ( 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-09 18:43:37 +02:00
this . tokenCount = "0" ;
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 ));
private Task SelectedToolIdsChanged ( HashSet < string > updatedToolIds )
{
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 ;
}
return Task . CompletedTask ;
}
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
{
2025-08-28 18:51:44 +02:00
{ x => x . Message , "Are you sure you want to start a new chat? All unsaved changes will be lost." },
2025-01-02 13:16:47 +01: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 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 )
{
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 18:37:18 +01:00
await WorkspaceBehaviour . DeleteChatAsync ( this . DialogService , this . ChatThread . WorkspaceId , this . ChatThread . ChatId , askForConfirmation : false );
2025-01-02 13:16:47 +01:00
else
2026-03-05 18:37:18 +01:00
await this . Workspaces . DeleteChatAsync ( chatPath , askForConfirmation : false , unloadChat : true );
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-09-04 15:48:07 +02:00
this . selectedToolIds = ToolSelectionRules . NormalizeSelection ( this . SettingsManager . GetDefaultToolIds ( Tools . Components . CHAT ));
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:
//
2025-01-02 13:16:47 +01:00
this . ChatThread = new ()
{
2026-01-18 20:36:04 +01:00
IncludeDateTime = true ,
2025-01-02 13:16:47 +01:00
SelectedProvider = this . Provider . Id ,
2025-01-02 14:50:54 +01:00
SelectedProfile = this . currentProfile . Id ,
2025-05-24 12:27:00 +02:00
SelectedChatTemplate = this . currentChatTemplate . Id ,
2026-09-04 15:48:07 +02:00
SelectedToolIds = [.. this . selectedToolIds ],
2025-01-02 14:50:54 +01:00
SystemPrompt = SystemPrompts . DEFAULT ,
2025-01-02 13:16:47 +01:00
WorkspaceId = this . currentWorkspaceId ,
ChatId = Guid . NewGuid (),
Name = string . Empty ,
2025-08-18 20:40:52 +02:00
Blocks = this . currentChatTemplate == ChatTemplate . NO_CHAT_TEMPLATE ? [] : this . currentChatTemplate . ExampleConversation . Select ( x => x . DeepClone ()). ToList (),
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
2025-02-15 15:41:12 +01:00
// Now, we have to reset the data source options as well:
this . ApplyStandardDataSourceOptions ();
// 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 );
}
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
};
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 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 ();
2025-02-15 15:41:12 +01:00
this . ApplyStandardDataSourceOptions ();
2025-01-21 15:36:22 +01:00
}
2025-01-21 19:12:36 +01: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 ();
2025-02-15 15:41:12 +01:00
this . ApplyStandardDataSourceOptions ();
2025-01-02 13:16:47 +01:00
await this . ChatThreadChanged . InvokeAsync ( this . ChatThread );
}
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 ;
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
var lastBlock = this . ChatThread . Blocks . Last ();
var lastBlockContent = lastBlock . Content ;
if ( lastBlockContent is null )
return Task . CompletedTask ;
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 ;
}
private Task EditLastBlock ( IContent block )
{
if ( this . ChatThread is null )
return Task . CompletedTask ;
if ( block is not ContentText textBlock )
return Task . CompletedTask ;
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-09 18:43:37 +02:00
private async Task CalculateTokenCount ()
{
if (! this . HasCustomTokenizer )
{
if ( this . tokenCount != "0" )
{
this . tokenCount = "0" ;
this . StateHasChanged ();
}
return ;
}
//
// 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. Counting is also
// triggered while parameters are set, which happens before that.
//
var currentInput = this . UserInput ;
if ( string . IsNullOrEmpty ( currentInput ))
{
this . tokenCount = "0" ;
return ;
}
var response = await this . RustService . GetTokenCount ( this . Provider , currentInput );
if ( response is null )
return ;
if (! response . Value . Success )
{
this . Logger . LogWarning ( "Failed to calculate token count: reason='{Reason}'" , response . Value . Message );
return ;
}
this . tokenCount = response . Value . TokenCount . ToString ();
this . StateHasChanged ();
}
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 :
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 ;
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
}