From 7d9a4f5ab107249be21aaf76706cbcc4f5e37f53 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 23 Aug 2026 21:15:37 +0200 Subject: [PATCH] Reduced memory usage and fixed several memory leaks (#933) --- .../Agenda/AssistantAgenda.razor.cs | 2 +- .../Assistants/AssistantBase.razor.cs | 6 ++ .../AssistantBatchProcessing.razor.Content.cs | 16 ++- .../Coding/AssistantCoding.razor.cs | 2 +- .../Assistants/EMail/AssistantEMail.razor.cs | 2 +- .../AssistantGrammarSpelling.razor.cs | 2 +- .../Assistants/I18N/allTexts.lua | 3 + .../IconFinder/AssistantIconFinder.razor.cs | 2 +- .../JobPosting/AssistantJobPostings.razor.cs | 2 +- .../LegalCheck/AssistantLegalCheck.razor.cs | 2 +- .../MyTasks/AssistantMyTasks.razor.cs | 2 +- .../AssistantPromptOptimizer.razor.cs | 2 +- .../AssistantRewriteImprove.razor.cs | 2 +- .../SlideBuilder/SlideAssistant.razor.cs | 2 +- .../Synonym/AssistantSynonyms.razor.cs | 2 +- .../AssistantTextSummarizer.razor.cs | 2 +- .../Translation/AssistantTranslation.razor.cs | 2 +- .../VisualBriefingAssistant.razor.Projects.cs | 5 + .../VisualBriefingAssistant.razor.cs | 2 +- .../VisualBriefingBuildOrchestrator.cs | 15 +++ .../VisualBriefingBuildProgressService.cs | 10 ++ .../VisualBriefingStore.Projects.cs | 1 + .../VisualBriefing/VisualBriefingStore.cs | 10 ++ .../Chat/ContentBlockComponent.razor.cs | 16 ++- app/MindWork AI Studio/Chat/ContentImage.cs | 4 +- app/MindWork AI Studio/Chat/ContentText.cs | 4 +- app/MindWork AI Studio/Chat/IContent.cs | 5 + .../Chat/IContentExtensions.cs | 20 ++++ .../Components/ChatComponent.razor.cs | 13 ++- .../Components/MSGComponentBase.cs | 35 ++++++- .../DataSourceERI_V1InfoDialog.razor.cs | 6 +- ...ataSourceLocalDirectoryInfoDialog.razor.cs | 6 +- .../Dialogs/DocumentCheckDialog.razor | 13 ++- .../Dialogs/DocumentCheckDialog.razor.cs | 97 ++++++++++++++++++- .../Layout/MainLayout.razor.cs | 4 +- .../MindWork AI Studio.csproj | 9 ++ .../plugin.lua | 3 + .../plugin.lua | 3 + app/MindWork AI Studio/Program.cs | 7 ++ .../Tools/AIJobs/AIJobService.cs | 84 +++++++++++++--- .../Tools/FileExtractionErrorCode.cs | 7 ++ app/MindWork AI Studio/Tools/MessageBus.cs | 49 +++++++--- .../Tools/PluginSystem/PluginBase.cs | 16 ++- .../PluginSystem/PluginFactory.HotReload.cs | 18 ++++ .../PluginSystem/PluginFactory.Remove.cs | 5 + .../PluginSystem/PluginFactory.Starting.cs | 9 ++ .../Tools/Services/RustService.Retrieval.cs | 31 +++++- app/MindWork AI Studio/Tools/UserFile.cs | 13 ++- .../Tools/WorkspaceBehaviour.cs | 23 ++++- .../wwwroot/changelog/v26.8.2.md | 5 + runtime/src/file_data.rs | 15 ++- 51 files changed, 531 insertions(+), 85 deletions(-) create mode 100644 app/MindWork AI Studio/Chat/IContentExtensions.cs diff --git a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs index b31bd188..aefdbb48 100644 --- a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs +++ b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs @@ -270,7 +270,7 @@ public partial class AssistantAgenda : AssistantBaseCore protected override async Task OnInitializedAsync() { - var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_AGENDA_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_AGENDA_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputContent = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 8f52ffa5..9621c81b 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -478,6 +478,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.CancellationTokenSource?.Dispose(); this.CancellationTokenSource = null; } + + // + // The handlers above close over this assistant, and the content stays in the chat + // thread. The stream is over by now, so nothing has to listen to it anymore: + // + aiText.ResetStreamingHandlers(); } } diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs index d971b802..b9dbee81 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs @@ -15,15 +15,15 @@ public partial class AssistantBatchProcessing { return IsTranscribableMedia(fileResult.FilePath) ? this.LoadMediaTranscriptAsync(fileResult, token) - : this.LoadDocumentContentAsync(fileResult); + : this.LoadDocumentContentAsync(fileResult, token); } - private async Task LoadDocumentContentAsync(BatchProcessingFileResult fileResult) + private async Task LoadDocumentContentAsync(BatchProcessingFileResult fileResult, CancellationToken token) { FileExtractionResult extraction; try { - extraction = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue); + extraction = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue, token: token); } catch (Exception e) { @@ -31,6 +31,16 @@ public partial class AssistantBatchProcessing return null; } + // + // The user stopped the batch run while we were reading this file. That says nothing about + // the file, so it gets the same status as a cancelled AI request instead of a failure: + // + if (extraction.ErrorCode is FileExtractionErrorCode.CANCELLED) + { + this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled.")); + return null; + } + if (!extraction.HasUsableContent) { this.Logger.LogError("Reading the batch file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", fileResult.FilePath, extraction.ErrorCode, extraction.ErrorMessage); diff --git a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs index 2e353dea..4df7b96a 100644 --- a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs +++ b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs @@ -143,7 +143,7 @@ public partial class AssistantCoding : AssistantBaseCore protected override async Task OnInitializedAsync() { - var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CODING_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_CODING_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.questions = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs index ee5d233a..1d1f2d31 100644 --- a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs +++ b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs @@ -124,7 +124,7 @@ public partial class AssistantEMail : AssistantBaseCore(Event.SEND_TO_EMAIL_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_EMAIL_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputBulletPoints = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs index ea6b1077..48b088f6 100644 --- a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs +++ b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs @@ -72,7 +72,7 @@ public partial class AssistantGrammarSpelling : AssistantBaseCore(Event.SEND_TO_GRAMMAR_SPELLING_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_GRAMMAR_SPELLING_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputText = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 693e8cfd..a989292f 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5698,6 +5698,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2129302565"] = "Load f -- Image View UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image View" +-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document." + -- See how we load your file. Review the content before we process it further. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further." diff --git a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs index 1134c175..68ba0d7c 100644 --- a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs +++ b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs @@ -78,7 +78,7 @@ public partial class AssistantIconFinder : AssistantBaseCore(Event.SEND_TO_ICON_FINDER_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_ICON_FINDER_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputContext = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs index d8826a8c..7b552606 100644 --- a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs +++ b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs @@ -177,7 +177,7 @@ public partial class AssistantJobPostings : AssistantBaseCore(Event.SEND_TO_JOB_POSTING_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_JOB_POSTING_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputJobDescription = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs index 80224ee4..68be4a20 100644 --- a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs +++ b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs @@ -90,7 +90,7 @@ public partial class AssistantLegalCheck : AssistantBaseCore(Event.SEND_TO_LEGAL_CHECK_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_LEGAL_CHECK_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputQuestions = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs index f66a7bb4..2805854d 100644 --- a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs +++ b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs @@ -139,7 +139,7 @@ public partial class AssistantMyTasks : AssistantBaseCore protected override async Task OnInitializedAsync() { - var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_MY_TASKS_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_MY_TASKS_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputText = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs index ae1c41af..344953cb 100644 --- a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs @@ -152,7 +152,7 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore(Event.SEND_TO_PROMPT_OPTIMIZER_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_PROMPT_OPTIMIZER_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputPrompt = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs index eb2cb493..f5c86e3c 100644 --- a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs +++ b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs @@ -77,7 +77,7 @@ public partial class AssistantRewriteImprove : AssistantBaseCore(Event.SEND_TO_REWRITE_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_REWRITE_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputText = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs index ffebd596..c3f7d2d9 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs @@ -256,7 +256,7 @@ public partial class SlideAssistant : AssistantBaseCore(Event.SEND_TO_SLIDE_BUILDER_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_SLIDE_BUILDER_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputContent = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs index 3acc0b08..e0d23e37 100644 --- a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs +++ b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs @@ -131,7 +131,7 @@ public partial class AssistantSynonyms : AssistantBaseCore(Event.SEND_TO_SYNONYMS_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_SYNONYMS_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputContext = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs index 62356f83..64fbcc5a 100644 --- a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs @@ -115,7 +115,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore(Event.SEND_TO_TEXT_SUMMARIZER_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_TEXT_SUMMARIZER_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputText = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs index b368f186..87f445c7 100644 --- a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs +++ b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs @@ -119,7 +119,7 @@ public partial class AssistantTranslation : AssistantBaseCore(Event.SEND_TO_TRANSLATION_ASSISTANT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_TRANSLATION_ASSISTANT).LastOrDefault(); if (deferredContent is not null) this.inputText = deferredContent; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs index e5cb607c..1182d0c0 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs @@ -138,6 +138,11 @@ public partial class VisualBriefingAssistant this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForVisualBriefing(id)); await this.Store.DeleteAsync(id); await this.Store.ForgetSelectionAsync(id); + + // The briefing is gone, so neither its build state nor its progress snapshot is of use: + this.BuildOrchestrator.ForgetBriefing(id); + this.BuildProgressService.Forget(id); + this.ClearSelectedProject(); await this.ReloadListAsync(); diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs index e9396100..435a9bef 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs @@ -158,7 +158,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase await this.ReloadListAsync(); await this.ConsumePendingMediaOutcomesAsync(); _ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token); - var deferredInstruction = this.MessageBus.CheckDeferredMessages(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).FirstOrDefault(); + var deferredInstruction = this.MessageBus.TakeDeferredMessages(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).LastOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInstruction)) { diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs index 18d815da..57c436eb 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.cs @@ -63,6 +63,21 @@ internal sealed partial class VisualBriefingBuildOrchestrator public VisualBriefingOperationDiagnostics? GetDiagnostics(Guid briefingId) => this.liveDiagnostics.GetValueOrDefault(briefingId); + /// + /// Drops what we kept for a briefing which does not exist anymore. + /// + /// + /// Both dictionaries only ever grew: every briefing which was built once stayed in them for as + /// long as the app was running. The build lock is not disposed, because another build might + /// still wait on it. + /// + /// The identifier of the deleted briefing. + public void ForgetBriefing(Guid briefingId) + { + this.buildLocks.TryRemove(briefingId, out _); + this.liveDiagnostics.TryRemove(briefingId, out _); + } + /// /// Builds or resumes a visual briefing operation. /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgressService.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgressService.cs index 295ed162..20ddef53 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgressService.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildProgressService.cs @@ -33,4 +33,14 @@ public sealed class VisualBriefingBuildProgressService /// public VisualBriefingBuildRecord? GetLatest(Guid briefingId) => this.latest.GetValueOrDefault(briefingId); + + /// + /// Drops the snapshot of a briefing which does not exist anymore. + /// + /// + /// A snapshot is a complete build record. Without this, every briefing which was ever built + /// kept one for as long as the app was running. + /// + /// The identifier of the deleted briefing. + public void Forget(Guid briefingId) => this.latest.TryRemove(briefingId, out _); } diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs index cd5f8aed..dec79e19 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs @@ -398,6 +398,7 @@ public sealed partial class VisualBriefingStore finally { gate.Release(); + this.ForgetLock(briefingId); } } diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.cs index 1510f32a..42cc8772 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.cs @@ -167,6 +167,16 @@ public sealed partial class VisualBriefingStore( /// private SemaphoreSlim GetLock(Guid briefingId) => this.briefingLocks.GetOrAdd(briefingId, _ => new(1, 1)); + /// + /// Drops the lock of a briefing which does not exist anymore. + /// + /// + /// Otherwise, this dictionary keeps one entry per briefing the app ever touched. We do not + /// dispose the semaphore: another operation might still wait on it, and disposing it under + /// their feet would turn a deleted briefing into an exception somewhere else. + /// + private void ForgetLock(Guid briefingId) => this.briefingLocks.TryRemove(briefingId, out _); + /// /// Defines BriefingDirectory for the visual briefing feature. /// diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 0dcb910c..fcacc3cb 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -8,7 +8,7 @@ namespace AIStudio.Chat; /// /// The UI component for a chat content block, i.e., for any IContent. /// -public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable +public partial class ContentBlockComponent : MSGComponentBase { private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer"; private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer"; @@ -601,16 +601,24 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable private async Task OpenAttachmentsDialog() { var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet()); - this.Content.FileAttachments = result.ToList(); + this.Content.FileAttachments = [.. result]; } - public async ValueTask DisposeAsync() + protected override async ValueTask DisposeResourcesAsync() { if (this.isDisposed) return; this.isDisposed = true; + + // + // Our handlers close over this component, while the content belongs to the chat thread and + // outlives us. We only detach what is still ours, though: when this content is streaming + // again, another component has registered its own handlers in the meantime. + // + if (this.Content.StreamingDone == this.AfterStreaming) + this.Content.ResetStreamingHandlers(); + await this.DisposeMathContainerIfNeededAsync(); - this.Dispose(); } } diff --git a/app/MindWork AI Studio/Chat/ContentImage.cs b/app/MindWork AI Studio/Chat/ContentImage.cs index 0eb36442..126e9833 100644 --- a/app/MindWork AI Studio/Chat/ContentImage.cs +++ b/app/MindWork AI Studio/Chat/ContentImage.cs @@ -22,11 +22,11 @@ public sealed class ContentImage : IContent, IImageSource /// [JsonIgnore] - public Func StreamingDone { get; set; } = () => Task.CompletedTask; + public Func StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER; /// [JsonIgnore] - public Func StreamingEvent { get; set; } = () => Task.CompletedTask; + public Func StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER; /// public List Sources { get; set; } = []; diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 4a3800ab..c1dc477c 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -37,11 +37,11 @@ public sealed class ContentText : IContent /// [JsonIgnore] - public Func StreamingDone { get; set; } = () => Task.CompletedTask; + public Func StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER; /// [JsonIgnore] - public Func StreamingEvent { get; set; } = () => Task.CompletedTask; + public Func StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER; /// public List Sources { get; set; } = []; diff --git a/app/MindWork AI Studio/Chat/IContent.cs b/app/MindWork AI Studio/Chat/IContent.cs index dea453f8..1bcca9f6 100644 --- a/app/MindWork AI Studio/Chat/IContent.cs +++ b/app/MindWork AI Studio/Chat/IContent.cs @@ -38,6 +38,11 @@ public interface IContent [JsonIgnore] public Func StreamingDone { get; set; } + /// + /// What a content does while nobody listens to its stream: nothing. + /// + public static readonly Func NO_STREAMING_HANDLER = () => Task.CompletedTask; + /// /// The provided sources, if any. /// diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs new file mode 100644 index 00000000..cfd3510d --- /dev/null +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -0,0 +1,20 @@ +namespace AIStudio.Chat; + +public static class IContentExtensions +{ + /// + /// Detaches whoever listens to the stream of this content. + /// + /// + /// The streaming handlers are closures over the component which registered them. A content + /// object belongs to the chat thread and therefore outlives every component which renders it, + /// so handlers left behind would keep those components alive for as long as the thread exists. + /// Whoever registers a handler calls this when it is no longer needed. + /// + /// The content whose streaming handlers you want to detach. + public static void ResetStreamingHandlers(this IContent content) + { + content.StreamingEvent = IContent.NO_STREAMING_HANDLER; + content.StreamingDone = IContent.NO_STREAMING_HANDLER; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 2cee066a..e9cefa6d 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -14,7 +14,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components; -public partial class ChatComponent : MSGComponentBase, IAsyncDisposable +public partial class ChatComponent : MSGComponentBase { private readonly Guid draftMediaOwnerId = Guid.NewGuid(); private const string CHAT_INPUT_ID = "chat-user-input"; @@ -131,7 +131,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); - var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CHAT_INPUT).FirstOrDefault(); + var deferredInput = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_CHAT_INPUT).LastOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInput)) this.ComposerState.SetUserInput(deferredInput); @@ -139,7 +139,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Check for deferred messages of the kind 'SEND_TO_CHAT', // aka the user sends an assistant result to the chat: // - var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CHAT).FirstOrDefault(); + var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages(Event.SEND_TO_CHAT).LastOrDefault(); if (deferredContent is not null) { // @@ -234,7 +234,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // component sends a message to the chat component to load // the chat with the bias: // - var deferredLoading = MessageBus.INSTANCE.CheckDeferredMessages(Event.LOAD_CHAT).FirstOrDefault(); + var deferredLoading = MessageBus.INSTANCE.TakeDeferredMessages(Event.LOAD_CHAT).LastOrDefault(); if (deferredLoading != default) { this.loadChat = deferredLoading; @@ -1288,9 +1288,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable #endregion - #region Implementation of IAsyncDisposable + #region Overrides of MSGComponentBase - public async ValueTask DisposeAsync() + protected override async ValueTask DisposeResourcesAsync() { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) @@ -1300,7 +1300,6 @@ 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/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index d2ff9d84..3c2e8ed8 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components; namespace AIStudio.Components; -public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver, ILang +public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDisposable, IMessageBusReceiver, ILang { [Inject] protected SettingsManager SettingsManager { get; init; } = null!; @@ -103,10 +103,20 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus this.MessageBus.ApplyFilters(this, filterComponents, eventsList.ToHashSet()); } + /// + /// Releases what this component has acquired. Override this instead of implementing + /// IDisposable again, so the deregistration from the message bus cannot be lost. + /// protected virtual void DisposeResources() { } - + + /// + /// Releases what this component has acquired and needs an await to release. Override this + /// instead of implementing IAsyncDisposable, see the remarks on DisposeAsync below. + /// + protected virtual ValueTask DisposeResourcesAsync() => ValueTask.CompletedTask; + #region Implementation of IDisposable public void Dispose() @@ -116,4 +126,25 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus } #endregion + + #region Implementation of IAsyncDisposable + + /// + /// Releases this component asynchronously. + /// + /// + /// This base class implements both ways of disposing on purpose. Blazor calls only DisposeAsync + /// when a component offers both, so a derived component which implements IAsyncDisposable on + /// its own would silently skip everything Dispose does — above all the deregistration from the + /// message bus, which holds a strong reference to every receiver. Deriving components override + /// DisposeResources or DisposeResourcesAsync instead, and this stays the one place which knows + /// about both. + /// + public async ValueTask DisposeAsync() + { + await this.DisposeResourcesAsync(); + this.Dispose(); + } + + #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs index 02d522b6..ba6382e1 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1InfoDialog.razor.cs @@ -15,7 +15,7 @@ using RetrievalInfo = AIStudio.Tools.ERIClient.DataModel.RetrievalInfo; namespace AIStudio.Dialogs; -public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDisposable, ISecretId +public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, ISecretId { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; @@ -186,9 +186,9 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos #endregion - #region Implementation of IDisposable + #region Overrides of MSGComponentBase - public async ValueTask DisposeAsync() + protected override async ValueTask DisposeResourcesAsync() { try { diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs index 08ec4408..8d7431ea 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryInfoDialog.razor.cs @@ -10,7 +10,7 @@ using Timer = System.Timers.Timer; namespace AIStudio.Dialogs; -public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsyncDisposable +public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; @@ -89,9 +89,9 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsy this.MudDialog.Close(); } - #region Implementation of IDisposable + #region Overrides of MSGComponentBase - public async ValueTask DisposeAsync() + protected override async ValueTask DisposeResourcesAsync() { try { diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor index 62fea886..403dd67e 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor @@ -8,7 +8,7 @@ @if (this.Document is null) { - + } else { @@ -51,6 +51,13 @@ } else { + @if (this.previewCutOffCharacters > 0) + { + + @string.Format(T("Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."), this.previewCutOffCharacters) + + } + @if (this.Document?.IsImage ?? false) { @@ -70,14 +77,14 @@ Class="ma-2 pe-4" HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
- +
+ /// How many characters we show at most. Rendering a huge document costs us a large Markdown + /// syntax tree and an equally large render tree. This dialog answers the question of how we + /// read the file, though — the beginning of the document is enough for that, and the AI still + /// receives the entire content. + /// + private const int PREVIEW_CHARACTER_LIMIT = 200_000; + /// /// Set when reading the file failed, so the dialog shows the reason instead of empty content. /// private string? loadFailureMessage; + /// + /// What we show to the user: either the entire file content, or its beginning. We keep this in + /// its own field so that we cut the content only once, instead of on every render. + /// + private string previewContent = string.Empty; + + /// + /// How many characters we cut off from the preview. Zero when we show the entire content. + /// + private int previewCutOffCharacters; + + /// + /// Ends the extraction when this dialog is gone before the file was read completely. + /// + private readonly CancellationTokenSource extractionCancellation = new(); + + /// + /// True once this dialog was disposed. The extraction runs across awaits, so it may return + /// long after the user closed the dialog — it must not touch this component afterwards. + /// + private bool isDisposed; + /// /// True while we extract the file content. Reading happens after the first render, so the /// dialog can tell the user that it is working instead of showing an empty document. @@ -54,6 +84,7 @@ public partial class DocumentCheckDialog : MSGComponentBase this.Document.Exists && string.IsNullOrWhiteSpace(this.FileContent); + this.UpdatePreview(); await base.OnInitializedAsync(); } @@ -66,7 +97,10 @@ public partial class DocumentCheckDialog : MSGComponentBase try { - var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService); + var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService, this.extractionCancellation.Token); + if (this.isDisposed) + return; + this.FileContent = extraction.Content; // @@ -76,6 +110,10 @@ public partial class DocumentCheckDialog : MSGComponentBase if (!extraction.HasUsableContent) this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName); } + catch (OperationCanceledException) + { + // The user closed this dialog while we were reading the file. Nothing left to do. + } catch (Exception ex) { this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document); @@ -84,14 +122,67 @@ public partial class DocumentCheckDialog : MSGComponentBase } finally { - this.isLoadingContent = false; - this.StateHasChanged(); + if (!this.isDisposed) + { + this.isLoadingContent = false; + this.UpdatePreview(); + this.StateHasChanged(); + } } } else if (firstRender) this.Logger.LogWarning("Document check dialog opened without a valid file path."); } + /// + /// Called when the user loads a file through this dialog. We don't use a two-way binding here, + /// since we have to refresh the preview whenever the content changes. + /// + /// The content of the file the user has loaded. + private void ApplyLoadedFileContent(string fileContent) + { + this.FileContent = fileContent; + this.UpdatePreview(); + } + + /// + /// Determines what part of the file content we show to the user. + /// + private void UpdatePreview() + { + if (this.FileContent.Length <= PREVIEW_CHARACTER_LIMIT) + { + this.previewContent = this.FileContent; + this.previewCutOffCharacters = 0; + return; + } + + // + // We cut at the last line break before our limit. Otherwise, we might tear apart a Markdown + // construct like a table row or a code fence in the middle of a line: + // + var cutIndex = this.FileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1; + if (cutIndex < 1) + cutIndex = PREVIEW_CHARACTER_LIMIT; + + this.previewContent = this.FileContent[..cutIndex]; + this.previewCutOffCharacters = this.FileContent.Length - cutIndex; + } + + /// + /// Ends a running extraction. Without this, reading a large document would continue after the + /// user closed this dialog and would keep this component, the extracted content, and the + /// response stream alive until the runtime is done. + /// + protected override void DisposeResources() + { + this.isDisposed = true; + this.extractionCancellation.Cancel(); + this.extractionCancellation.Dispose(); + + base.DisposeResources(); + } + private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default; private MudMarkdownStyling MarkdownStyling => new() diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index eb121095..43596965 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -292,8 +292,10 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // // Check if there is an enterprise configuration plugin to download: // + // Every deferred environment matters here: each one is a configuration + // to download, so this is the one place which uses all of them. var enterpriseEnvironments = this.MessageBus - .CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) + .TakeDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) .Where(env => env != default) .ToList(); diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index f104b82c..e5b9ecd3 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -26,6 +26,15 @@ true true + + false + true +