Replaced visual briefing special cases with component capabilities & fixed briefing session clearing

This commit is contained in:
Thorsten Sommer 2026-08-02 12:10:15 +02:00
parent 832b5e32cc
commit bb418ab5b1
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
7 changed files with 111 additions and 20 deletions

View File

@ -606,15 +606,17 @@ public abstract partial class AssistantBase<TSettings> : 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)

View File

@ -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();
}
}
/// <summary>
/// Consumes the finished session of one briefing while this component is still showing it.
/// </summary>
/// <remarks>
/// 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
/// <c>AssistantBase</c> does. When the user has navigated away, we keep it so the overview can
/// report that a background build has finished.
/// </remarks>
/// <param name="sessionKey">The session key of the briefing that just finished.</param>
private void RetireFinishedSession(AssistantSessionKey sessionKey)
{
if (!this.isDisposed)
_ = this.AssistantSessionService.TryTakeInactiveSnapshot(sessionKey);
}
/// <summary>
/// Automatically resumes the selected build that was active when the app stopped.
/// </summary>

View File

@ -190,6 +190,9 @@ public partial class VisualBriefingAssistant : MSGComponentBase
/// <summary>Requests validation after conditional form controls have rendered.</summary>
private bool formValidationPending;
/// <summary>Stores whether this component instance has already left the renderer.</summary>
private bool isDisposed;
/// <summary>
/// Defines <c>IsCurrentBusy</c> for the visual briefing feature.
/// </summary>
@ -237,6 +240,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase
/// </summary>
protected override void DisposeResources()
{
this.isDisposed = true;
this.sourceMonitorCancellation.Cancel();
this.sourceMonitorCancellation.Dispose();
this.buildDurationMonitorCancellation.Cancel();

View File

@ -103,14 +103,29 @@ public partial class AssistantBlock<TSettings> : 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));
/// <summary>
/// Gets whether a media-import owner belongs to the assistant represented by this block.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="owner">The media-import owner to test.</param>
/// <returns><c>true</c> when this block represents the owner.</returns>
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;
}
/// <summary>
/// Gets the assistant session indicator shown on top of the assistant icon.
@ -143,13 +158,7 @@ public partial class AssistantBlock<TSettings> : 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);
}

View File

@ -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<FileAttachment>()
: 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));

View File

@ -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,
};
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Components return <c>false</c> 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.
/// </remarks>
/// <param name="component">The component to look up.</param>
/// <returns><c>true</c> when the component owns exactly one session slot.</returns>
public static bool HasSingleSessionSlot(this Components component) => component switch
{
Components.CHAT => false,
Components.VISUAL_BRIEFING_ASSISTANT => false,
_ => true,
};
/// <summary>
/// Gets the kind of media-import owner a component creates for its attachments.
/// </summary>
/// <param name="component">The component to look up.</param>
/// <returns>The media-import owner kind.</returns>
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,

View File

@ -0,0 +1,19 @@
namespace AIStudio.Tools.Media;
/// <summary>Capabilities of a media-import owner kind.</summary>
public static class MediaImportOwnerKindExtensions
{
/// <summary>
/// Gets whether the owner stores its own source list and transcripts.
/// </summary>
/// <remarks>
/// Owners that persist their own sources take the attached media over immediately and keep it
/// next to the stored document, see <see cref="MediaImportOwnerKind.VISUAL_BRIEFING"/>. 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.
/// </remarks>
/// <param name="kind">The owner kind to look up.</param>
/// <returns><c>true</c> when the owner persists its own sources.</returns>
public static bool PersistsOwnSources(this MediaImportOwnerKind kind) => kind is MediaImportOwnerKind.VISUAL_BRIEFING;
}