From bb418ab5b1b03b53b85f1f0bd7c28d22ec98cd6d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 2 Aug 2026 12:10:15 +0200 Subject: [PATCH] Replaced visual briefing special cases with component capabilities & fixed briefing session clearing --- .../Assistants/AssistantBase.razor.cs | 8 ++-- .../VisualBriefingAssistant.razor.Build.cs | 20 ++++++++++ .../VisualBriefingAssistant.razor.cs | 4 ++ .../Components/AssistantBlock.razor.cs | 39 ++++++++++++------- .../Components/AttachDocuments.razor.cs | 8 +++- .../Tools/ComponentsExtensions.cs | 33 ++++++++++++++++ .../Media/MediaImportOwnerKindExtensions.cs | 19 +++++++++ 7 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Media/MediaImportOwnerKindExtensions.cs diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index a945016b..bbe42411 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -606,15 +606,17 @@ public abstract partial class AssistantBase : AssistantLowerBase wher }; var sendToData = destination.GetData(); - if (destination is not Tools.Components.CHAT and not Tools.Components.VISUAL_BRIEFING_ASSISTANT && - this.AssistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && snapshot.Key.Component == destination)) + if (destination.HasSingleSessionSlot() && this.AssistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && snapshot.Key.Component == destination)) { await this.MessageBus.SendWarning(new(Icons.Material.Filled.Apps, this.TB("This assistant is already running. AI Studio opens the running session instead."))); this.NavigationManager.NavigateTo(sendToData.Route); return; } - if (destination is not Tools.Components.CHAT) + // Only components with a single session slot may be cleared as a group. The visual briefing + // assistant keys its sessions per briefing, so clearing by component would discard the + // status of every stored briefing instead of the one we are about to open. + if (destination.HasSingleSessionSlot()) await this.AssistantSessionService.ClearInactiveSessionsForComponentAsync(destination); switch (destination) diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs index 91e08ab2..eede041a 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs @@ -200,6 +200,8 @@ public partial class VisualBriefingAssistant null, new(StringComparer.Ordinal), this); + + this.RetireFinishedSession(sessionKey); this.generatingBriefings.Remove(briefingId); this.StateHasChanged(); } @@ -290,11 +292,29 @@ public partial class VisualBriefingAssistant finally { await this.AssistantSessionService.CompleteAsync(sessionKey, session.SessionId, terminalStatus, terminalIssue, null, new(StringComparer.Ordinal), this); + this.RetireFinishedSession(sessionKey); this.generatingBriefings.Remove(briefingId); this.StateHasChanged(); } } + /// + /// Consumes the finished session of one briefing while this component is still showing it. + /// + /// + /// A briefing session carries no state, because the briefing itself is stored on disk. Its only + /// remaining purpose after completion is the indicator on the assistant overview. When the user + /// is still on this page, that indicator would be stale, so we retire the session the same way + /// AssistantBase does. When the user has navigated away, we keep it so the overview can + /// report that a background build has finished. + /// + /// The session key of the briefing that just finished. + private void RetireFinishedSession(AssistantSessionKey sessionKey) + { + if (!this.isDisposed) + _ = this.AssistantSessionService.TryTakeInactiveSnapshot(sessionKey); + } + /// /// Automatically resumes the selected build that was active when the app stopped. /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs index 6248ffc7..f308e298 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs @@ -190,6 +190,9 @@ public partial class VisualBriefingAssistant : MSGComponentBase /// Requests validation after conditional form controls have rendered. private bool formValidationPending; + /// Stores whether this component instance has already left the renderer. + private bool isDisposed; + /// /// Defines IsCurrentBusy for the visual briefing feature. /// @@ -237,6 +240,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase /// protected override void DisposeResources() { + this.isDisposed = true; this.sourceMonitorCancellation.Cancel(); this.sourceMonitorCancellation.Dispose(); this.buildDurationMonitorCancellation.Cancel(); diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index 3fb88916..ff639a0c 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -103,14 +103,29 @@ public partial class AssistantBlock : MSGComponentBase where TSetting private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(new AssistantSessionKey(this.Component, this.AssistantSessionInstanceId)); - private MediaImportSnapshot? MediaImportSnapshot => this.Component is Tools.Components.VISUAL_BRIEFING_ASSISTANT - ? this.MediaTranscriptionService.GetSnapshots().FirstOrDefault(snapshot => - snapshot.Owner.Kind is MediaImportOwnerKind.VISUAL_BRIEFING) - : string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId) - ? this.MediaTranscriptionService.GetSnapshots().FirstOrDefault(snapshot => - snapshot.Owner.Kind is MediaImportOwnerKind.ASSISTANT - && snapshot.Owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal)) - : this.MediaTranscriptionService.GetSnapshot(this.CurrentMediaImportOwner); + private MediaImportSnapshot? MediaImportSnapshot => this.MediaTranscriptionService.GetSnapshots() + .FirstOrDefault(snapshot => this.OwnedByThisBlock(snapshot.Owner)); + + /// + /// Gets whether a media-import owner belongs to the assistant represented by this block. + /// + /// + /// Owners that persist their own sources are keyed by the stored document rather than by an + /// assistant session, so this block aggregates all of them for its component. Without a session + /// instance we aggregate every owner of the component, otherwise we match the exact owner. + /// + /// The media-import owner to test. + /// true when this block represents the owner. + private bool OwnedByThisBlock(MediaImportOwner owner) + { + if (owner.Kind.PersistsOwnSources()) + return owner.Kind == this.Component.MediaOwnerKind(); + + if (string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId)) + return owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal); + + return owner == this.CurrentMediaImportOwner; + } /// /// Gets the assistant session indicator shown on top of the assistant icon. @@ -143,13 +158,7 @@ public partial class AssistantBlock : MSGComponentBase where TSetting private void OnMediaImportStateChanged(MediaImportOwner owner) { - var matches = this.Component is Tools.Components.VISUAL_BRIEFING_ASSISTANT - ? owner.Kind is MediaImportOwnerKind.VISUAL_BRIEFING - : string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId) - ? owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal) - : owner == this.CurrentMediaImportOwner; - - if (matches) + if (this.OwnedByThisBlock(owner)) _ = this.InvokeAsync(this.StateHasChanged); } diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index d30abab4..9309a5b7 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -184,7 +184,9 @@ public partial class AttachDocuments : MSGComponentBase private async Task SyncCompletedMediaAttachmentsAsync() { var delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget); - var completed = this.EffectiveImportOwner.Kind is MediaImportOwnerKind.VISUAL_BRIEFING + // Owners that persist their own sources have already taken the media over when the batch + // started, so re-adding the delivered transcripts here would duplicate them. + var completed = this.EffectiveImportOwner.Kind.PersistsOwnSources() ? Array.Empty() : delivery?.Attachments ?? []; var pending = this.OwnerChat?.PendingMediaTranscripts ?? []; @@ -493,7 +495,9 @@ public partial class AttachDocuments : MSGComponentBase if (this.OwnerChat is null) this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]); - if (this.EffectiveImportOwner.Kind is MediaImportOwnerKind.VISUAL_BRIEFING) + // Owners that persist their own sources show the file right away and keep it next to the + // stored document, instead of waiting for the transcription to be delivered back. + if (this.EffectiveImportOwner.Kind.PersistsOwnSources()) { foreach (var mediaPath in mediaPaths) this.DocumentPaths.Add(FileAttachment.FromPath(mediaPath)); diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 5ef4ee23..8e1501aa 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools; @@ -24,6 +25,38 @@ public static class ComponentsExtensions _ => PreviewFeatures.NONE, }; + /// + /// Gets whether a component owns exactly one assistant session slot, so that a running session + /// blocks starting another one and inactive sessions can be cleared as a group. + /// + /// + /// Components return false for two different reasons. The chat has no assistant sessions + /// at all. The visual briefing assistant keys its sessions per briefing, so it owns one slot per + /// stored briefing rather than one per component. Both must be excluded from the single-slot + /// checks, which is why this is a capability and not a component comparison. + /// + /// The component to look up. + /// true when the component owns exactly one session slot. + public static bool HasSingleSessionSlot(this Components component) => component switch + { + Components.CHAT => false, + Components.VISUAL_BRIEFING_ASSISTANT => false, + + _ => true, + }; + + /// + /// Gets the kind of media-import owner a component creates for its attachments. + /// + /// The component to look up. + /// The media-import owner kind. + public static MediaImportOwnerKind MediaOwnerKind(this Components component) => component switch + { + Components.VISUAL_BRIEFING_ASSISTANT => MediaImportOwnerKind.VISUAL_BRIEFING, + + _ => MediaImportOwnerKind.ASSISTANT, + }; + public static bool AllowSendTo(this Components component) => component switch { Components.NONE => false, diff --git a/app/MindWork AI Studio/Tools/Media/MediaImportOwnerKindExtensions.cs b/app/MindWork AI Studio/Tools/Media/MediaImportOwnerKindExtensions.cs new file mode 100644 index 00000000..44ce0454 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Media/MediaImportOwnerKindExtensions.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Tools.Media; + +/// Capabilities of a media-import owner kind. +public static class MediaImportOwnerKindExtensions +{ + /// + /// Gets whether the owner stores its own source list and transcripts. + /// + /// + /// Owners that persist their own sources take the attached media over immediately and keep it + /// next to the stored document, see . The + /// attachment control must therefore neither wait for the transcription to finish before showing + /// the file, nor deliver the completed transcripts back into its own list afterwards, because + /// the owner already holds them. All other owners rely on that delivery instead. + /// + /// The owner kind to look up. + /// true when the owner persists its own sources. + public static bool PersistsOwnSources(this MediaImportOwnerKind kind) => kind is MediaImportOwnerKind.VISUAL_BRIEFING; +} \ No newline at end of file