diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index db4c1ee1..e431e719 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -37,7 +37,7 @@ - + } - + - + - + - + - + - + @@ -137,7 +137,7 @@ @if (this.IsCurrentChatStreaming) { - + } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index b17a582b..ded3427f 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -38,6 +38,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable [Parameter] public Workspaces? Workspaces { get; set; } + + [Parameter] + public ChatComposerState ComposerState { get; set; } = new(); [Inject] private ILogger Logger { get; set; } = null!; @@ -62,7 +65,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private bool mustScrollToBottomAfterRender; private InnerScrolling scrollingArea = null!; private byte scrollRenderCountdown; - private string userInput = string.Empty; private bool mustStoreChat; private bool mustLoadChat; private LoadChat loadChat; @@ -70,20 +72,36 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable 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 HashSet chatDocumentPaths = []; // Unfortunately, we need the input field reference to blur the focus away. Without // this, we cannot clear the input field. private MudTextField 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() { // Apply the filters for the message bus: - this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.WORKSPACE_LOADED_CHAT_CHANGED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); + 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 ]); // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); @@ -94,15 +112,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Get the preselected chat template: this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT); - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; + if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent) + this.ComposerState.ApplyTemplate(this.currentChatTemplate); var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CHAT_INPUT).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInput)) - this.userInput = deferredInput; - - // Apply template's file attachments, if any: - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.SetUserInput(deferredInput); // // Check for deferred messages of the kind 'SEND_TO_CHAT', @@ -120,6 +135,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.IncludeDateTime = true; 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, @@ -246,6 +262,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable 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."); @@ -276,13 +293,35 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable protected override async Task OnParametersSetAsync() { - await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.ApplyLoadedChatParameterAsync(); await this.SyncForegroundChatAsync(); 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); @@ -424,12 +463,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { this.currentChatTemplate = chatTemplate; if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt)) - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; + this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt); // Apply template's file attachments (replaces existing): - this.chatDocumentPaths.Clear(); - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); if(this.ChatThread is null) return; @@ -489,6 +526,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.dataSourceSelectionComponent.Hide(); this.hasUnsavedChanges = true; + this.ComposerState.MarkUserDraft(); var key = keyEvent.Code.ToLowerInvariant(); // Was the enter key (either enter or numpad enter) pressed? @@ -520,7 +558,16 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.dataSourceSelectionComponent?.IsVisible ?? false) this.dataSourceSelectionComponent.Hide(); - this.userInput = await this.JsRuntime.InvokeAsync("formatChatInputMarkdown", CHAT_INPUT_ID, formatType); + 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; } @@ -548,17 +595,18 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable WorkspaceId = this.currentWorkspaceId, ChatId = Guid.NewGuid(), DataSourceOptions = this.earlyDataSourceOptions, - Name = this.ExtractThreadName(this.userInput), + Name = this.ExtractThreadName(this.ComposerState.UserInput), Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(), }; + 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.userInput); + this.ChatThread.Name = this.ExtractThreadName(this.ComposerState.UserInput); // Update provider, profile and chat template: this.ChatThread.SelectedProvider = this.Provider.Id; @@ -575,14 +623,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable IContent? lastUserPrompt; if (!reuseLastUserPrompt) { - var normalizedAttachments = this.chatDocumentPaths + var normalizedAttachments = this.ComposerState.FileAttachments .Select(attachment => attachment.Normalize()) .Where(attachment => attachment.IsValid) .ToList(); lastUserPrompt = new ContentText { - Text = this.userInput, + Text = this.ComposerState.UserInput, FileAttachments = normalizedAttachments, }; @@ -629,8 +677,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Clear the input field: await this.inputField.FocusAsync(); - this.userInput = string.Empty; - this.chatDocumentPaths.Clear(); + this.ComposerState.Clear(); await this.inputField.BlurAsync(); @@ -724,7 +771,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Reset our state: // this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); // // Reset the LLM provider considering the user's settings: @@ -781,18 +828,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable }; } - this.userInput = this.currentChatTemplate.PredefinedUserPrompt; - - // Apply template's file attachments: - this.chatDocumentPaths.Clear(); - foreach (var attachment in this.currentChatTemplate.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + 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); } @@ -834,26 +877,33 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); this.ChatThread!.WorkspaceId = workspaceId; + this.MarkCurrentChatAsLoadedParameter(); await this.SaveThread(); await this.SyncWorkspaceHeaderWithChatThreadAsync(); } - private async Task LoadedChatChanged() + private async Task LoadedChatChanged(bool notifyParent = true) { this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); if (this.ChatThread is not null) { this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread; - await this.ChatThreadChanged.InvokeAsync(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); } else { + this.loadedParameterChatId = Guid.Empty; + this.loadedParameterWorkspaceId = Guid.Empty; this.ClearWorkspaceHeaderState(); await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); @@ -872,10 +922,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private async Task ResetState() { this.hasUnsavedChanges = false; - this.userInput = string.Empty; + this.ComposerState.Clear(); this.ClearWorkspaceHeaderState(); this.ChatThread = null; + this.MarkCurrentChatAsLoadedParameter(); await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); @@ -974,11 +1025,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private void RestoreComposerFromTextBlock(ContentText textBlock) { - this.userInput = textBlock.Text; - this.chatDocumentPaths.Clear(); - - foreach (var attachment in textBlock.FileAttachments) - this.chatDocumentPaths.Add(attachment.Normalize()); + this.ComposerState.RestoreFromTextBlock(textBlock); } #region Overrides of MSGComponentBase @@ -1000,10 +1047,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SaveThread(); break; - case Event.WORKSPACE_LOADED_CHAT_CHANGED: - await this.LoadedChatChanged(); - break; - case Event.AI_JOB_CHANGED: case Event.AI_JOB_FINISHED: case Event.CHAT_GENERATION_CHANGED: @@ -1030,7 +1073,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (this.IsCurrentChatStreaming) return Task.FromResult((TResult?) (object) false); - return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); + return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft)); } return Task.FromResult(default(TResult)); @@ -1049,6 +1092,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable } await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); + this.Dispose(); } #endregion diff --git a/app/MindWork AI Studio/Components/ChatComposerState.cs b/app/MindWork AI Studio/Components/ChatComposerState.cs new file mode 100644 index 00000000..a1565611 --- /dev/null +++ b/app/MindWork AI Studio/Components/ChatComposerState.cs @@ -0,0 +1,65 @@ +using AIStudio.Chat; +using AIStudio.Settings; + +namespace AIStudio.Components; + +public sealed class ChatComposerState +{ + public string UserInput { get; private set; } = string.Empty; + + public HashSet FileAttachments { get; } = []; + + public bool HasUserDraft { get; private set; } + + public bool HasComposerContent => !string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0; + + public bool HasVisibleUserDraft => this.HasUserDraft && (!string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0); + + public void ApplyTemplate(ChatTemplate chatTemplate) + { + this.UserInput = chatTemplate.PredefinedUserPrompt; + this.FileAttachments.Clear(); + foreach (var attachment in chatTemplate.FileAttachments) + this.FileAttachments.Add(attachment.Normalize()); + + this.HasUserDraft = false; + } + + public void SetUserInput(string? userInput) + { + this.UserInput = userInput ?? string.Empty; + this.HasUserDraft = !string.IsNullOrWhiteSpace(userInput); + } + + public void SetSystemInput(string? userInput) + { + this.UserInput = userInput ?? string.Empty; + this.HasUserDraft = false; + } + + public void MarkUserDraft() + { + this.HasUserDraft = true; + } + + public void ReplaceFileAttachments(IEnumerable fileAttachments) + { + this.FileAttachments.Clear(); + foreach (var attachment in fileAttachments) + this.FileAttachments.Add(attachment.Normalize()); + } + + public void Clear() + { + this.UserInput = string.Empty; + this.FileAttachments.Clear(); + this.HasUserDraft = false; + } + + public void RestoreFromTextBlock(ContentText textBlock) + { + this.UserInput = textBlock.Text; + this.ReplaceFileAttachments(textBlock.FileAttachments); + this.HasUserDraft = true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index c8220d33..ef9c15c8 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -236,12 +236,13 @@ public partial class Workspaces : MSGComponentBase private string GetChatTreeIcon(Guid chatId, string defaultIcon) { var snapshot = this.AIJobService.TryGetChatSnapshot(chatId); - return snapshot?.Status switch + if (snapshot is null || !snapshot.IsActive) + return defaultIcon; + + return snapshot.Status switch { AIJobStatus.WAITING_FOR_REMOTE => Icons.Material.Filled.HourglassTop, AIJobStatus.RUNNING => Icons.Material.Filled.ChangeCircle, - AIJobStatus.CANCELED => Icons.Material.Filled.Cancel, - AIJobStatus.FAILED => Icons.Material.Filled.Error, _ => defaultIcon, }; } @@ -390,7 +391,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread = chat; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } return chat; @@ -439,7 +439,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread = null; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } } @@ -473,7 +472,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread.Name = chat.Name; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } await WorkspaceBehaviour.StoreChatAsync(chat); @@ -596,7 +594,6 @@ public partial class Workspaces : MSGComponentBase { this.CurrentChatThread = chat; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); - await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } await WorkspaceBehaviour.StoreChatAsync(chat); diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index b1b48dc3..f35a00a6 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -89,6 +89,7 @@ @@ -115,6 +116,7 @@ @@ -125,6 +127,7 @@ } diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 9adef5bb..0f271076 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -26,6 +26,7 @@ public partial class Chat : MSGComponentBase private string currentWorkspaceName = string.Empty; private Workspaces? workspaces; private double splitterPosition = 30; + private readonly ChatComposerState composerState = new(); private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6)); diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index 0fb14711..7619b6f7 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -17,12 +17,16 @@ public sealed class AIJobService( { public required CancellationTokenSource CancellationTokenSource { get; init; } + public required CancellationToken CancellationToken { get; init; } + public required ChatGenerationRequest ChatGenerationRequest { get; init; } public required AIJobSnapshot Snapshot { get; set; } public DateTimeOffset LastCheckpoint { get; set; } + public bool IsCompletionStarted { get; set; } + public readonly Lock SyncRoot = new(); } @@ -96,9 +100,11 @@ public sealed class AIJobService( UpdatedAt = DateTimeOffset.Now, }; + var cancellationTokenSource = new CancellationTokenSource(); var state = new AIJobState { - CancellationTokenSource = new CancellationTokenSource(), + CancellationTokenSource = cancellationTokenSource, + CancellationToken = cancellationTokenSource.Token, ChatGenerationRequest = request, Snapshot = snapshot, LastCheckpoint = DateTimeOffset.MinValue, @@ -131,8 +137,23 @@ public sealed class AIJobService( if (!this.jobs.TryGetValue(jobId, out var job)) return; - if (!job.CancellationTokenSource.IsCancellationRequested) - await job.CancellationTokenSource.CancelAsync(); + lock (job.SyncRoot) + { + if (job.IsCompletionStarted) + return; + } + + try + { + if (!job.CancellationTokenSource.IsCancellationRequested) + await job.CancellationTokenSource.CancelAsync(); + } + catch (ObjectDisposedException) + { + return; + } + + await this.CompleteChatGenerationAsync(job, AIJobStatus.CANCELED); } public async Task CancelChatGenerationAsync(Guid chatId) @@ -167,13 +188,14 @@ public sealed class AIJobService( private async Task RunChatGenerationAsync(AIJobState state) { var request = state.ChatGenerationRequest; - var token = state.CancellationTokenSource.Token; + var token = state.CancellationToken; try { + token.ThrowIfCancellationRequested(); + var provider = request.ProviderSettings.CreateProvider(); var chatThread = request.ChatThread; - var aiText = request.AIText; if (!chatThread.IsLLMProviderAllowed(provider)) { @@ -188,6 +210,8 @@ public sealed class AIJobService( return; } + token.ThrowIfCancellationRequested(); + try { var rag = new AISrcSelWithRetCtxVal(); @@ -207,21 +231,18 @@ public sealed class AIJobService( logger.LogError(e, "Skipping the RAG process due to an error."); } + token.ThrowIfCancellationRequested(); + var lastStreamingEvent = DateTimeOffset.MinValue; - aiText.InitialRemoteWait = true; + if (!TrySetWaitingForRemote(state, token)) + return; await this.NotifyChangedAsync(state); await foreach (var contentStreamChunk in provider.StreamChatCompletion(request.ProviderSettings.Model, chatThread, settingsManager, token)) { - if (token.IsCancellationRequested) + if (!TryApplyStreamChunk(state, contentStreamChunk, token)) break; - aiText.InitialRemoteWait = false; - aiText.IsStreaming = true; - aiText.Text += contentStreamChunk; - aiText.Sources.MergeSources(contentStreamChunk.Sources); - - UpdateStatus(state, AIJobStatus.RUNNING); var now = DateTimeOffset.Now; if (!settingsManager.ConfigurationData.App.IsSavingEnergy || now - lastStreamingEvent > STREAMING_EVENT_MIN_TIME) { @@ -255,11 +276,21 @@ public sealed class AIJobService( private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus status, string errorMessage = "") { + lock (state.SyncRoot) + { + if (state.IsCompletionStarted) + return; + + state.IsCompletionStarted = true; + } + var aiText = state.ChatGenerationRequest.AIText; aiText.InitialRemoteWait = false; aiText.IsStreaming = false; aiText.Text = aiText.Text.RemoveThinkTags().Trim(); + RemoveEmptyAIResponse(state); + lock (state.SyncRoot) { state.Snapshot = state.Snapshot with @@ -290,18 +321,41 @@ public sealed class AIJobService( state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock); } - private static void UpdateStatus(AIJobState state, AIJobStatus status) + private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token) { lock (state.SyncRoot) { - if (state.Snapshot.Status == status) - return; + if (state.IsCompletionStarted || token.IsCancellationRequested) + return false; - state.Snapshot = state.Snapshot with + state.ChatGenerationRequest.AIText.InitialRemoteWait = true; + return true; + } + } + + private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk contentStreamChunk, CancellationToken token) + { + lock (state.SyncRoot) + { + if (state.IsCompletionStarted || token.IsCancellationRequested) + return false; + + var aiText = state.ChatGenerationRequest.AIText; + aiText.InitialRemoteWait = false; + aiText.IsStreaming = true; + aiText.Text += contentStreamChunk; + aiText.Sources.MergeSources(contentStreamChunk.Sources); + + if (state.Snapshot.Status is not AIJobStatus.RUNNING) { - Status = status, - UpdatedAt = DateTimeOffset.Now, - }; + state.Snapshot = state.Snapshot with + { + Status = AIJobStatus.RUNNING, + UpdatedAt = DateTimeOffset.Now, + }; + } + + return true; } } diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index f7feb24a..d0e3452b 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -64,11 +64,11 @@ public sealed class MessageBus { foreach (var (receiver, componentFilter) in this.componentFilters) { - if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent)) + if (componentFilter.Length > 0 && message.SendingComponent is not null && !componentFilter.Contains(message.SendingComponent)) continue; var eventFilter = this.componentEvents[receiver]; - if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) + if (eventFilter.Length == 0 || eventFilter.Contains(message.TriggeredEvent)) // We don't await the task here because we don't want to block the message bus: _ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index e705efb1..31772f31 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -15,8 +15,10 @@ - Fixed an issue where attached documents were detached when editing a previous prompt. They now remain attached. - Fixed an issue where failed transcription requests could be shown as empty transcription results instead of a clear error message. - Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel. +- Fixed an issue with switching between chat threads while multiple chats are running. - Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading. - Fixed missing translations for file type names in file selection dialogs. +- Fixed the chat user prompt being cleared when toggling the workspace view. - Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system. - Upgraded Rust to v1.95.0. - Upgraded .NET to v9.0.16.