mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 06:53:48 +00:00
Hardened background work against disconnected circuits (#937)
This commit is contained in:
1 parent
99089f853e
commit
f03c2d1c88
44 files changed
+500
-126
No files matched your search
@@ -167,7 +167,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase, IAssistantCat
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (this.OwnedByThisBlock(owner))
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<TSettings>)}: rendering a media import change");
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
|
||||
@@ -140,12 +140,12 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.SyncCompletedMediaAttachmentsAsync();
|
||||
await this.ConsumeStandaloneMediaOutcomeAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(AttachDocuments)}: syncing media attachments");
|
||||
}
|
||||
|
||||
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
|
||||
@@ -225,7 +225,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
// Release the drop area. Without this, drop areas below this one would count this component
|
||||
// forever and would stop catching dropped files:
|
||||
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer);
|
||||
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(AttachDocuments)}: releasing the drop area");
|
||||
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
@@ -261,11 +261,11 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(ChatComponent)}: consuming a media import outcome");
|
||||
}
|
||||
|
||||
/// <summary>Consumes a terminal media notification when its chat is visible.</summary>
|
||||
|
||||
@@ -80,9 +80,10 @@ public partial class CodeEditor : ComponentBase, IAsyncDisposable
|
||||
if (this.module is null)
|
||||
return;
|
||||
|
||||
await this.module.TryInvokeVoidAsync("destroy", this.editorId);
|
||||
|
||||
try
|
||||
{
|
||||
await this.module.InvokeVoidAsync("destroy", this.editorId);
|
||||
await this.module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
|
||||
@@ -64,7 +64,7 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
|
||||
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationDirectory)}: applying the changed directory");
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ public partial class ConfigurationFile : ConfigurationBaseCore
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
|
||||
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationFile)}: applying the changed file");
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ public partial class ConfigurationText : ConfigurationBaseCore
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
|
||||
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationText)}: applying the changed text");
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ public partial class DebouncedTextField : MudComponentBase, IDisposable
|
||||
this.debounceTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
this.debounceTimer.Stop();
|
||||
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text));
|
||||
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text));
|
||||
this.InvokeAsync(() => this.WhenTextCanged(this.text));
|
||||
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: notifying about changed text");
|
||||
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text asynchronously");
|
||||
this.InvokeAsync(() => this.WhenTextCanged(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text");
|
||||
};
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -13,6 +14,13 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
|
||||
[Inject]
|
||||
protected MessageBus MessageBus { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The circuit this component lives in. Use it before any JS interop: while its connection is down,
|
||||
/// the browser is unreachable, although the component itself keeps working.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected CircuitStateService CircuitState { get; init; } = null!;
|
||||
|
||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
@@ -21,7 +29,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
|
||||
{
|
||||
this.Lang = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
|
||||
this.MessageBus.RegisterComponent(this);
|
||||
this.MessageBus.RegisterComponent(this, this.CircuitState);
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ public partial class MediaTranscriptionStatus
|
||||
private void OnStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.Owner)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(MediaTranscriptionStatus)}: rendering an import state transition");
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from singleton import state changes.</summary>
|
||||
|
||||
@@ -164,6 +164,6 @@ public partial class PluginDeleteAction : MSGComponentBase
|
||||
private void OnMediaTranscriptionStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal))
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(PluginDeleteAction)}: rendering a transcription state change");
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,12 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.SyncCompletedMediaTextAsync();
|
||||
await this.ConsumeStandaloneMediaOutcomeAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(ReadFileContent)}: syncing transcribed text");
|
||||
}
|
||||
|
||||
/// <summary>Consumes outcomes for dialog-local controls that have no assistant owner surface.</summary>
|
||||
@@ -195,7 +195,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
// Release the drop area. Without this, drop areas below this one would count this component
|
||||
// forever and would stop catching dropped files:
|
||||
if (this.EnableDragDrop)
|
||||
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer);
|
||||
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(ReadFileContent)}: releasing the drop area");
|
||||
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")">
|
||||
|
||||
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => _ = this.UpdateLangBehaviour(selectedValue))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
|
||||
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => this.UpdateLangBehaviour(selectedValue).Observe(nameof(this.UpdateLangBehaviour)))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
|
||||
@if (this.SettingsManager.ConfigurationData.App.LanguageBehavior is LangBehavior.MANUAL)
|
||||
{
|
||||
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => _ = this.UpdateManuallySelectedLanguage(selectedValue))" OptionHelp="@T("Select the language for the app.")"/>
|
||||
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => this.UpdateManuallySelectedLanguage(selectedValue).Observe(nameof(this.UpdateManuallySelectedLanguage)))" OptionHelp="@T("Select the language for the app.")"/>
|
||||
}
|
||||
|
||||
<ConfigurationSelect OptionDescription="@T("Color theme")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="@T("Choose the color theme that best suits for you.")"/>
|
||||
|
||||
@@ -152,35 +152,10 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (runtimeState.Backend is ShortcutBackend.LOCAL
|
||||
&& !runtimeState.IsSuspended
|
||||
&& !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync(
|
||||
"localShortcut.register",
|
||||
"voice-recording-toggle",
|
||||
runtimeState.Shortcut,
|
||||
this.localShortcutDotNetReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
|
||||
}
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled.");
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener.");
|
||||
}
|
||||
if (runtimeState.Backend is ShortcutBackend.LOCAL && !runtimeState.IsSuspended && !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.register", "voice-recording-toggle", runtimeState.Shortcut, this.localShortcutDotNetReference);
|
||||
else
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle");
|
||||
}
|
||||
|
||||
private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)
|
||||
@@ -561,13 +536,27 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
/// <summary>
|
||||
/// Hands the focused-window shortcut back to the browser before this component goes away.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This belongs into the asynchronous part of the disposal: the base class runs it before
|
||||
/// DisposeResources, and only here we can await the call. Discarding it instead left the
|
||||
/// unregistration unfinished, and its failure on an already-disconnected circuit surfaced as an
|
||||
/// unobserved task exception once the finalizer got to it.
|
||||
/// </remarks>
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
if (this.localShortcutInteropReady)
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle");
|
||||
|
||||
await base.DisposeResourcesAsync();
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged;
|
||||
|
||||
if (this.localShortcutInteropReady)
|
||||
_ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
|
||||
|
||||
this.localShortcutDotNetReference?.Dispose();
|
||||
this.localShortcutDotNetReference = null;
|
||||
this.localShortcutInteropReady = false;
|
||||
|
||||
@@ -63,7 +63,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]);
|
||||
_ = this.LoadTreeItemsAsync(startPrefetch: true);
|
||||
this.LoadTreeItemsAsync(startPrefetch: true).Observe($"{nameof(Workspaces)}: loading the workspace tree");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -445,7 +445,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.CHAT)
|
||||
_ = this.SafeStateHasChanged();
|
||||
this.SafeStateHasChanged().Observe($"{nameof(Workspaces)}: rendering a media import change");
|
||||
}
|
||||
|
||||
private async Task SafeStateHasChanged()
|
||||
|
||||
Reference in new issue
Block a user