using System.Globalization; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.AIJobs; using AIStudio.Tools.Media; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components; public partial class ChatComponent : MSGComponentBase { private readonly Guid draftMediaOwnerId = Guid.NewGuid(); 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"; [Parameter] public ChatThread? ChatThread { get; set; } [Parameter] public EventCallback ChatThreadChanged { get; set; } [Parameter] public AIStudio.Settings.Provider Provider { get; set; } = AIStudio.Settings.Provider.NONE; [Parameter] public EventCallback ProviderChanged { get; set; } [Parameter] public Action WorkspaceName { get; set; } = _ => { }; [Parameter] public Workspaces? Workspaces { get; set; } [Parameter] public ChatComposerState ComposerState { get; set; } = new(); [Inject] private ILogger Logger { get; set; } = null!; [Inject] private ToolRegistry ToolRegistry { get; set; } = null!; [Inject] private IDialogService DialogService { get; init; } = null!; [Inject] private ConversationTokenCounter ConversationTokenCounter { get; init; } = null!; [Inject] private IJSRuntime JsRuntime { get; init; } = null!; [Inject] private AIJobService AIJobService { get; init; } = null!; [Inject] private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!; private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); private DataSourceSelection? dataSourceSelectionComponent; private DataSourceOptions earlyDataSourceOptions = new(); private DataSourceOptions lastAppliedStandardDataSourceOptions = new(); private Profile currentProfile = Profile.NO_PROFILE; private ChatTemplate currentChatTemplate = ChatTemplate.NO_CHAT_TEMPLATE; 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; private HashSet selectedToolIds = []; private bool previousInputForbidden = true; private Guid lastSeenChatId = Guid.Empty; private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE; private string currentWorkspaceName = string.Empty; private Guid currentWorkspaceId = Guid.Empty; private Guid currentChatThreadId = Guid.Empty; private Guid loadedParameterChatId = Guid.Empty; private Guid loadedParameterWorkspaceId = Guid.Empty; private Guid foregroundChatId = Guid.Empty; private int workspaceHeaderSyncVersion; private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE; /// /// How much of the window must be used before the number starts saying so. /// private const double WINDOW_NEARLY_FULL = 0.8d; /// /// How long the token count stays quiet after it ran, while nothing is being written. /// /// /// 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. /// private static readonly TimeSpan TOKEN_COUNT_QUIET_TIME = TimeSpan.FromMilliseconds(500); /// /// How long the token count stays quiet while an answer is being written. /// /// /// 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. /// private static readonly TimeSpan TOKEN_COUNT_STREAMING_QUIET_TIME = TimeSpan.FromSeconds(3); /// /// How long the token count waits for a reason before counting anyway. /// /// /// 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. /// private static readonly TimeSpan TOKEN_COUNT_HEARTBEAT = TimeSpan.FromSeconds(10); /// /// Recomputes the token count whenever something might have changed. /// private ConversationTokenTracker? tokenTracker; /// /// How long to leave the token count alone after it ran. /// private TimeSpan TokenCountQuietTime() => this.IsCurrentChatStreaming ? TOKEN_COUNT_STREAMING_QUIET_TIME : TOKEN_COUNT_QUIET_TIME; /// /// The culture the token numbers are written in. /// /// /// 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. /// private CultureInfo currentCulture = CultureInfo.InvariantCulture; /// /// What the helper text under the input field says about the token budget. /// /// /// 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. /// 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}"; } } /// /// Takes over the culture of the language the user chose for AI Studio. /// private async Task RefreshCulture() { var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin(); this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag); } private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId); // Unfortunately, we need the input field reference to blur the focus away. Without // this, we cannot clear the input field. private UserPromptComponent inputField = null!; /// /// Represents the user's input in the chat interface. /// /// /// 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. /// private string UserInput { get => this.ComposerState.UserInput; set => this.ComposerState.SetUserInput(value); } #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; 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(); // Apply the filters for the message bus: 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 ]); // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); USER_INPUT_ATTRIBUTES["id"] = CHAT_INPUT_ID; // Get the preselected profile: this.currentProfile = this.SettingsManager.GetPreselectedProfile(Tools.Components.CHAT); // Get the preselected chat template: this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT); if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent) this.ComposerState.ApplyTemplate(this.currentChatTemplate); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT)); this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); var deferredInput = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_CHAT_INPUT).LastOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInput)) this.ComposerState.SetUserInput(deferredInput); // // Check for deferred messages of the kind 'SEND_TO_CHAT', // aka the user sends an assistant result to the chat: // var deferredRequest = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_CHAT).LastOrDefault(); if (deferredRequest is not null) { // // Yes, the user sent an assistant result to the chat. // // Use chat thread sent by the user: this.ChatThread = deferredRequest.ChatThread; this.ChatThread.IncludeDateTime = true; this.ApplyToolSelectionOfLoadedChat(); // // 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)); this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now."); this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); // We know already that the chat thread is not null, // but we have to check it again for the nullability // for the compiler: if (this.ChatThread is not null) { // // Check if the chat thread has a name. If not, we // generate the name now: // 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" }; } } // // Check if the user wants to apply the standard chat data source options: // if (!deferredRequest.PreserveDataSourceOptions && this.SettingsManager.ConfigurationData.Chat.SendToChatDataSourceBehavior is SendToChatDataSourceBehavior.APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS) this.ChatThread.DataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); // // Check if the user wants to store the chat automatically: // if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { this.autoSaveEnabled = true; this.mustStoreChat = true; // // When a standard workspace is used, we have to ensure // that the workspace is available: // if(this.ChatThread.WorkspaceId == KnownWorkspaces.ERI_SERVER_WORKSPACE_ID) await WorkspaceBehaviour.EnsureERIServerWorkspace(); else if (this.ChatThread.WorkspaceId == KnownWorkspaces.BIAS_WORKSPACE_ID) await WorkspaceBehaviour.EnsureBiasWorkspace(); } } } else { // // No, the user did not send an assistant result to the chat. // this.ApplyStandardDataSourceOptions(); } // // Check if the user wants to show the latest message after loading: // if (this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading) { // // 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.: // this.mustScrollToBottomAfterRender = true; this.scrollRenderCountdown = 4; this.StateHasChanged(); } // // 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: // var deferredLoading = MessageBus.INSTANCE.TakeDeferredMessages(Event.LOAD_CHAT).LastOrDefault(); if (deferredLoading != default) { this.loadChat = deferredLoading; this.mustLoadChat = true; this.Logger.LogInformation($"The loading of the chat '{this.loadChat.ChatId}' was deferred and will be loaded now."); } // // 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: // if (this.ChatThread is not null) await this.SyncWorkspaceHeaderWithChatThreadAsync(); // Select the correct provider: await this.SelectProviderWhenLoadingChat(); await this.SyncForegroundChatAsync(); await this.ConsumeMediaOutcomeAsync(); await base.OnInitializedAsync(); } /// Refreshes send and attachment controls when the media import lane changes. private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.CurrentMediaImportOwner) this.InvokeAsync(async () => { await this.ConsumeMediaOutcomeAsync(); this.StateHasChanged(); }).Observe($"{nameof(ChatComponent)}: consuming a media import outcome"); } /// Consumes a terminal media notification when its chat is visible. 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."))); } } 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) await this.Workspaces.StoreChatAsync(this.ChatThread); else await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); await this.SyncWorkspaceHeaderWithChatThreadAsync(); } if (firstRender && this.mustLoadChat) { this.Logger.LogInformation($"Try to load the chat '{this.loadChat.ChatId}' now."); this.mustLoadChat = false; this.ChatThread = await WorkspaceBehaviour.LoadChatAsync(this.loadChat); if(this.ChatThread is not null) { this.MarkCurrentChatAsLoadedParameter(); 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."); this.ApplyToolSelectionOfLoadedChat(); await this.SyncWorkspaceHeaderWithChatThreadAsync(); await this.SelectProviderWhenLoadingChat(); } else this.Logger.LogWarning($"The chat '{this.loadChat.ChatId}' could not be loaded."); this.StateHasChanged(); } if(this.mustScrollToBottomAfterRender) { if (--this.scrollRenderCountdown == 0) { await this.scrollingArea.ScrollToBottom(); this.mustScrollToBottomAfterRender = false; } else { this.StateHasChanged(); } } var inputForbidden = this.IsInputForbidden(); if (!inputForbidden && this.previousInputForbidden) await this.inputField.FocusAsync(); this.previousInputForbidden = inputForbidden; // // 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(); await base.OnAfterRenderAsync(firstRender); } protected override async Task OnParametersSetAsync() { 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; } await this.ApplyLoadedChatParameterAsync(); await this.SyncForegroundChatAsync(); await this.ConsumeMediaOutcomeAsync(); await base.OnParametersSetAsync(); } #endregion 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; } private async Task SyncWorkspaceHeaderWithChatThreadAsync() { var syncVersion = Interlocked.Increment(ref this.workspaceHeaderSyncVersion); var currentChatThread = this.ChatThread; if (currentChatThread is null) { this.ClearWorkspaceHeaderState(); 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. if (this.currentChatThreadId == currentChatThread.ChatId && this.currentWorkspaceId == currentChatThread.WorkspaceId) return; 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); } private void PublishWorkspaceNameIfChanged(string workspaceName) { // Only notify the parent when the name actually changed to prevent // an infinite render loop: WorkspaceName -> UpdateWorkspaceName -> // StateHasChanged -> re-render -> OnParametersSetAsync -> WorkspaceName -> ... if (this.currentWorkspaceName == workspaceName) return; this.currentWorkspaceName = workspaceName; this.WorkspaceName(this.currentWorkspaceName); } 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); } 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); } private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE; private bool IsCurrentChatStreaming => this.ChatThread is not null && this.AIJobService.IsChatGenerationActive(this.ChatThread.ChatId); 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"); } } private bool CanThreadBeSaved => this.ChatThread is not null && this.ChatThread.Blocks.Any(b => !b.HideFromUser); private string TooltipAddChatToWorkspace => string.Format(T("Start new chat in workspace '{0}'"), this.currentWorkspaceName); 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)} {this.TokenBudgetClass}".Trim(); /// /// How much of the model's context window the conversation already takes. /// /// /// 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. /// private double TokenBudgetFill => this.conversationTokens is { IsKnown: true, Window.IsKnown: true } ? (double) this.conversationTokens.Tokens / this.conversationTokens.Window.DefaultTokens : 0d; /// /// What the number under the input field is coloured with, if anything. /// /// /// 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. /// 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; } } private void ApplyStandardDataSourceOptions() { var chatDefaultOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); this.lastAppliedStandardDataSourceOptions = chatDefaultOptions.CreateCopy(); this.earlyDataSourceOptions = chatDefaultOptions; if(this.ChatThread is not null) this.ChatThread.DataSourceOptions = chatDefaultOptions; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(chatDefaultOptions); } 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); } 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)); threadName = threadName.Trim(); // Remove all line breaks: threadName = threadName.Replace("\r", string.Empty); threadName = threadName.Replace("\n", " "); threadName = threadName.Replace("\t", " "); // 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) { this.currentProfile = this.SettingsManager.GetProfileById(profile.Id); // // 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) { this.ChatThread = this.ChatThread with { SelectedProfile = this.currentProfile.Id, }; await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } } private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate) { this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id); if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt)) this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt); // Apply template's file attachments (replaces existing): this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); if (this.ChatThread is not null) await this.StartNewChat(true); } private void RefreshCurrentProfileAndChatTemplate() { this.currentProfile = this.SettingsManager.GetProfileById(this.currentProfile.Id); this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(this.currentChatTemplate.Id); } private async Task RefreshChatSelectionsAfterConfigurationChange() { var previousProvider = this.Provider; var previousChatTemplate = this.currentChatTemplate; this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(this.Provider.Id); 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); await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange(); } private IReadOnlyList GetAgentSelectedDataSources() { if (this.ChatThread is null) return []; return this.ChatThread.AISelectedDataSources; } 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; } private bool IsInputForbidden() { if (!this.IsProviderSelected) return true; if(this.IsCurrentChatStreaming) return true; if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) return true; return false; } private async Task InputKeyEvent(KeyboardEventArgs keyEvent) { if(this.dataSourceSelectionComponent?.IsVisible ?? false) this.dataSourceSelectionComponent.Hide(); this.hasUnsavedChanges = true; this.ComposerState.MarkUserDraft(); 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; // 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; } } private async Task ApplyMarkdownFormat(string formatType) { if (this.IsInputForbidden()) return; if(this.dataSourceSelectionComponent?.IsVisible ?? false) this.dataSourceSelectionComponent.Hide(); this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync("formatChatInputMarkdown", CHAT_INPUT_ID, formatType)); this.hasUnsavedChanges = true; } private void ComposerAttachmentsChanged(HashSet attachments) { if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments)) this.ComposerState.ReplaceFileAttachments(attachments); this.ComposerState.MarkUserDraft(); this.hasUnsavedChanges = true; } /// Creates and stores a stable draft immediately after media import confirmation. private async Task EnsureMediaImportChatAsync(string firstMediaPath) { if (this.ChatThread is not null) return this.ChatThread; this.RefreshCurrentProfileAndChatTemplate(); var promptName = this.ExtractThreadName(this.ComposerState.UserInput); var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput) ? $"Transkription: {Path.GetFileName(firstMediaPath)}" : promptName; this.ChatThread = this.NewChatThread(threadName) with { DataSourceOptions = this.earlyDataSourceOptions, }; await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.SyncForegroundChatAsync(); return this.ChatThread; } private async Task SendMessage(bool reuseLastUserPrompt = false) { if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)) return; await this.RefreshProviderSelectionFromConfigurationAsync(); if (!this.IsProviderSelected) return; if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) return; this.RefreshCurrentProfileAndChatTemplate(); // Blur the focus away from the input field to be able to clear it: await this.inputField.BlurAsync(); // Create a new chat thread if necessary: if (this.ChatThread is null) { this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with { DataSourceOptions = this.earlyDataSourceOptions, }; this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } else { // Set the thread name if it is empty: if (string.IsNullOrWhiteSpace(this.ChatThread.Name)) this.ChatThread.Name = this.ExtractThreadName(this.ComposerState.UserInput); // Update provider, profile and chat template: this.ChatThread.SelectedProvider = this.Provider.Id; this.ChatThread.SelectedProfile = this.currentProfile.Id; // // 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. // } var time = DateTimeOffset.Now; IContent? lastUserPrompt; if (!reuseLastUserPrompt) { var normalizedAttachments = this.ComposerState.FileAttachments .Select(attachment => attachment.Normalize()) .Where(attachment => attachment.IsValid) .ToList(); lastUserPrompt = new ContentText { Text = this.ComposerState.UserInput, FileAttachments = normalizedAttachments, }; this.ChatThread.PendingMediaTranscripts.Clear(); // // Add the user message to the thread: // this.ChatThread?.Blocks.Add(new ContentBlock { Time = time, ContentType = ContentType.TEXT, Role = ChatRole.USER, Content = lastUserPrompt, }); // Save the chat: if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { await this.SaveThread(); this.hasUnsavedChanges = false; this.StateHasChanged(); } } else lastUserPrompt = this.ChatThread.Blocks.Last(x => x.Role is ChatRole.USER).Content; // // 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: await this.inputField.FocusAsync(); this.ComposerState.Clear(); await this.inputField.BlurAsync(); // 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}'."); this.StateHasChanged(); this.ChatThread!.RuntimeComponent = Tools.Components.CHAT; this.ChatThread.SelectedToolIds = [..this.selectedToolIds]; this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds); await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest { ChatThread = this.ChatThread, AIText = aiText, LastUserPrompt = lastUserPrompt, ProviderSettings = this.Provider, IsForeground = true, }); await this.SyncForegroundChatAsync(); this.StateHasChanged(); } private async Task CancelStreaming() { if (this.ChatThread is not null) await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId); } /// /// Takes over the tool selection of the chat that was just loaded or handed to this component. /// /// /// 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. /// private void ApplyToolSelectionOfLoadedChat() => this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT)); private void SelectedToolIdsChanged(HashSet 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; } } 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) await this.Workspaces.StoreChatAsync(this.ChatThread); else await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); this.hasUnsavedChanges = false; } private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false) { // // Want the user to manage the chat storage manually? In that case, we have to ask the user // about possible data loss: // if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var dialogParameters = new DialogParameters { { x => x.Message, "Are you sure you want to start a new chat? All unsaved changes will be lost." }, }; var dialogReference = await this.DialogService.ShowAsync("Delete Chat", dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; } // // Delete the previous chat when desired and necessary: // 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) await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); else await this.Workspaces.DeleteChatAsync(chatPath, askForConfirmation: false, unloadChat: true); } // // Reset our state: // this.hasUnsavedChanges = false; this.ComposerState.Clear(); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT)); this.RefreshCurrentProfileAndChatTemplate(); // // Reset the LLM provider considering the user's settings: // 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: if(this.Provider == AIStudio.Settings.Provider.NONE) { this.Provider = this.SettingsManager.GetPreselectedProvider(Tools.Components.CHAT); await this.ProviderChanged.InvokeAsync(this.Provider); } break; } // // Reset the chat thread or create a new one: // if (!useSameWorkspace) { // // 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: // this.ChatThread = null; this.ClearWorkspaceHeaderState(); } else { // // 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: // this.ChatThread = this.NewChatThread(string.Empty); } this.ComposerState.ApplyTemplate(this.currentChatTemplate); // Now, we have to reset the data source options as well: this.ApplyStandardDataSourceOptions(); // Notify the parent component about the change: await this.SyncForegroundChatAsync(); this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } private async Task MoveChatToWorkspace() { if(this.ChatThread is null) return; if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var confirmationDialogParameters = new DialogParameters { { x => x.Message, T("Are you sure you want to move this chat? All unsaved changes will be lost.") }, }; var confirmationDialogReference = await this.DialogService.ShowAsync("Unsaved Changes", confirmationDialogParameters, DialogOptions.FULLSCREEN); var confirmationDialogResult = await confirmationDialogReference.Result; if (confirmationDialogResult is null || confirmationDialogResult.Canceled) return; } var dialogParameters = new DialogParameters { { x => x.Message, T("Please select the workspace where you want to move the chat to.") }, { x => x.SelectedWorkspace, this.ChatThread?.WorkspaceId ?? Guid.Empty }, { x => x.ConfirmText, T("Move chat") }, }; var dialogReference = await this.DialogService.ShowAsync(T("Move Chat to Workspace"), dialogParameters, DialogOptions.FULLSCREEN_MANUAL_ESCAPE); 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; await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId); this.MarkCurrentChatAsLoadedParameter(); await this.SyncWorkspaceHeaderWithChatThreadAsync(); } private async Task LoadedChatChanged(bool notifyParent = true) { this.hasUnsavedChanges = false; this.ComposerState.Clear(); if (this.ChatThread is not null) { this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread; this.loadedParameterChatId = this.ChatThread.ChatId; this.loadedParameterWorkspaceId = this.ChatThread.WorkspaceId; if (notifyParent) await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.SyncWorkspaceHeaderWithChatThreadAsync(); await this.SyncForegroundChatAsync(); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); this.ApplyToolSelectionOfLoadedChat(); } else { this.loadedParameterChatId = Guid.Empty; this.loadedParameterWorkspaceId = Guid.Empty; this.ClearWorkspaceHeaderState(); await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); } await this.SelectProviderWhenLoadingChat(); if (this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading) { this.mustScrollToBottomAfterRender = true; this.scrollRenderCountdown = 2; } this.StateHasChanged(); } 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); } private async Task ResetState() { this.hasUnsavedChanges = false; this.ComposerState.Clear(); this.ClearWorkspaceHeaderState(); this.ChatThread = null; this.MarkCurrentChatAsLoadedParameter(); await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } private async Task SelectProviderWhenLoadingChat() { var chatProvider = this.ChatThread?.SelectedProvider; var chatProfile = this.ChatThread?.SelectedProfile; var chatChatTemplate = this.ChatThread?.SelectedChatTemplate; this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProvider); await this.ProviderChanged.InvokeAsync(this.Provider); // Try to select the profile: if (!string.IsNullOrWhiteSpace(chatProfile)) this.currentProfile = this.SettingsManager.GetProfileById(chatProfile); // Try to select the chat template: if (!string.IsNullOrWhiteSpace(chatChatTemplate)) this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatChatTemplate); } private async Task ToggleWorkspaceOverlay() { await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_TOGGLE_OVERLAY); } private async Task RemoveBlock(IContent block) { if(this.ChatThread is null) return; this.ChatThread.Remove(block); this.hasUnsavedChanges = true; await this.SaveThread(); this.StateHasChanged(); } private async Task RegenerateBlock(IContent aiBlock) { if(this.ChatThread is null) return; if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) return; this.ChatThread.Remove(aiBlock, removeForRegenerate: true); this.hasUnsavedChanges = true; this.StateHasChanged(); await this.SendMessage(reuseLastUserPrompt: true); } 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; this.RestoreComposerFromTextBlock(textBlock); 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; this.RestoreComposerFromTextBlock(textBlock); this.ChatThread.Remove(block); this.hasUnsavedChanges = true; this.StateHasChanged(); return Task.CompletedTask; } private void RestoreComposerFromTextBlock(ContentText textBlock) { this.ComposerState.RestoreFromTextBlock(textBlock); } /// /// Works out what the next request would take out of the model's context window. /// /// /// 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. /// /// Ends the count when the component goes away. private async Task RecountTokensAsync(CancellationToken token) { var provider = AIStudio.Settings.Provider.NONE; var parts = ConversationParts.NOTHING; // // 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. // await this.InvokeAsync(() => { // // 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()); }); var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token); if (token.IsCancellationRequested) return; await this.InvokeAsync(() => { if (counted == this.conversationTokens) return; this.conversationTokens = counted; this.StateHasChanged(); }); } /// /// Works out the system prompt a thread would send. /// /// /// 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. /// /// The thread to build the prompt for. /// The system prompt as it would be sent. 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; } /// /// The thread a new chat starts with, as the selections made so far decide it. /// /// /// 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. /// /// The name of the thread. /// The new thread. 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(), }; #region Overrides of MSGComponentBase protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default { switch (triggeredEvent) { case Event.RESET_CHAT_STATE: await this.ResetState(); break; case Event.CHAT_STREAMING_DONE: // 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; if(this.autoSaveEnabled) await this.SaveThread(); break; case Event.WORKSPACE_RENAMED: if (data is Guid workspaceId) await this.RefreshRenamedWorkspaceHeaderAsync(workspaceId); break; case Event.CONFIGURATION_CHANGED: case Event.PLUGINS_RELOADED: await this.RefreshCulture(); await this.RefreshChatSelectionsAfterConfigurationChange(); this.StateHasChanged(); break; 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) { this.hasUnsavedChanges = false; this.previousInputForbidden = true; } this.StateHasChanged(); } break; } } protected override Task ProcessIncomingMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default { 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); if (this.IsCurrentChatStreaming) return Task.FromResult((TResult?) (object) false); return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft)); } return Task.FromResult(default(TResult)); } #endregion #region Overrides of MSGComponentBase protected override async ValueTask DisposeResourcesAsync() { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; if (this.tokenTracker is not null) await this.tokenTracker.DisposeAsync(); if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { await this.SaveThread(); this.hasUnsavedChanges = false; } await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); } #endregion }