mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-01 17:49:14 +00:00
Hardened background work against disconnected circuits (#937)
This commit is contained in:
parent
99089f853e
commit
f03c2d1c88
@ -170,10 +170,15 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
}
|
||||
|
||||
this.formChangeTimer.AutoReset = false;
|
||||
this.formChangeTimer.Elapsed += async (_, _) =>
|
||||
//
|
||||
// Mind the missing async here: a timer hands its elapsed event to a thread pool thread, where an
|
||||
// async handler has nobody to hand its exception to. Such an exception is not merely unobserved,
|
||||
// it is unhandled, and it takes the app down with it. Observing the task keeps it contained.
|
||||
//
|
||||
this.formChangeTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
this.formChangeTimer.Stop();
|
||||
await this.OnFormChange();
|
||||
this.OnFormChange().Observe($"{nameof(AssistantBase<TSettings>)}: handling a form change");
|
||||
};
|
||||
|
||||
this.MightPreselectValues();
|
||||
@ -327,7 +332,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1);
|
||||
this.InputIssues[^1] = issue;
|
||||
this.InputIsValid = false;
|
||||
_ = this.RefreshAssistantUIAsync();
|
||||
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<TSettings>)}: rendering an added input issue");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -337,7 +342,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
{
|
||||
this.InputIssues = [];
|
||||
this.InputIsValid = true;
|
||||
_ = this.RefreshAssistantUIAsync();
|
||||
this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<TSettings>)}: rendering cleared input issues");
|
||||
}
|
||||
|
||||
protected void CreateChatThread()
|
||||
@ -733,11 +738,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = this.InvokeAsync(async () =>
|
||||
this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(AssistantBase<TSettings>)}: consuming a media import outcome");
|
||||
}
|
||||
|
||||
/// <summary>Consumes a terminal media notification when this assistant is visible.</summary>
|
||||
|
||||
@ -90,7 +90,7 @@ public partial class AssistantI18N : AssistantBaseCore<SettingsDialogI18N>
|
||||
this.customTargetLanguage = string.Empty;
|
||||
}
|
||||
|
||||
_ = this.OnChangedLanguage();
|
||||
this.OnChangedLanguage().Observe($"{nameof(AssistantI18N)}: applying a language change");
|
||||
}
|
||||
|
||||
protected override bool MightPreselectValues()
|
||||
|
||||
@ -460,7 +460,7 @@ public partial class AssistantLogViewer : MSGComponentBase
|
||||
{
|
||||
this.StopAutoRefresh();
|
||||
this.autoRefreshCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = this.AutoRefreshLoopAsync(this.autoRefreshCancellationTokenSource.Token);
|
||||
this.AutoRefreshLoopAsync(this.autoRefreshCancellationTokenSource.Token).Observe($"{nameof(AssistantLogViewer)}: refreshing the log automatically");
|
||||
}
|
||||
|
||||
private void StopAutoRefresh()
|
||||
|
||||
@ -246,14 +246,14 @@ public partial class VisualBriefingAssistant
|
||||
if (this.selectedBriefing?.BriefingId != briefingId)
|
||||
return;
|
||||
|
||||
_ = this.InvokeAsync(() =>
|
||||
this.InvokeAsync(() =>
|
||||
{
|
||||
if (this.selectedBriefing?.BriefingId != briefingId)
|
||||
return;
|
||||
|
||||
this.latestBuild = this.BuildProgressService.GetLatest(briefingId);
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(VisualBriefingAssistant)}: rendering the build progress");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -265,7 +265,7 @@ public partial class VisualBriefingAssistant
|
||||
: briefing.Versions.OrderByDescending(version => version.VersionNumber).FirstOrDefault()?.RevisionId ?? Guid.Empty;
|
||||
|
||||
if (revisionId != Guid.Empty)
|
||||
_ = this.SelectRevisionAsync(revisionId);
|
||||
this.SelectRevisionAsync(revisionId).Observe($"{nameof(VisualBriefingAssistant)}: selecting a revision");
|
||||
else
|
||||
{
|
||||
this.selectedRevisionId = Guid.Empty;
|
||||
|
||||
@ -136,7 +136,7 @@ public partial class VisualBriefingAssistant
|
||||
!Guid.TryParse(owner.Id, out var briefingId))
|
||||
return;
|
||||
|
||||
_ = this.InvokeAsync(async () =>
|
||||
this.InvokeAsync(async () =>
|
||||
{
|
||||
await this.ConsumeMediaOutcomeAsync(owner);
|
||||
if (!this.MediaTranscriptionService.IsBusy(owner))
|
||||
@ -152,7 +152,7 @@ public partial class VisualBriefingAssistant
|
||||
}
|
||||
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(VisualBriefingAssistant)}: consuming a media import outcome");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -157,7 +157,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase
|
||||
this.BuildProgressService.Changed += this.BuildProgressChanged;
|
||||
await this.ReloadListAsync();
|
||||
await this.ConsumePendingMediaOutcomesAsync();
|
||||
_ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token);
|
||||
this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token).Observe($"{nameof(VisualBriefingAssistant)}: monitoring the source status");
|
||||
var deferredInstruction = this.MessageBus.TakeDeferredMessages<string>(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).LastOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deferredInstruction))
|
||||
|
||||
@ -56,7 +56,7 @@ public partial class VisualBriefingBuildProgress : MSGComponentBase
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
_ = this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token);
|
||||
this.MonitorBuildDurationAsync(this.durationMonitorCancellation.Token).Observe($"{nameof(VisualBriefingBuildProgress)}: monitoring the build duration");
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
|
||||
@ -245,7 +245,13 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
if (string.Equals(this.lastMathRenderSignature, mathRenderSignature, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature);
|
||||
//
|
||||
// Remember what the browser shows only when it really got the call: otherwise, a call which was
|
||||
// lost while the connection was down would make us skip the math rendering after the reconnect.
|
||||
//
|
||||
if (!await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_SYNC_FUNCTION, this.mathContentContainer, mathRenderSignature))
|
||||
return;
|
||||
|
||||
this.lastMathRenderSignature = mathRenderSignature;
|
||||
this.hasActiveMathContainer = true;
|
||||
}
|
||||
@ -258,16 +264,7 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync(CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, CHAT_MATH_DISPOSE_FUNCTION, this.mathContentContainer);
|
||||
|
||||
this.hasActiveMathContainer = false;
|
||||
this.lastMathRenderSignature = string.Empty;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -179,17 +179,23 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
/// <summary>
|
||||
/// Removes the escape key handler from the browser before this dialog goes away.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The base class runs this before DisposeResources, which is what lets us await the call. The
|
||||
/// previous attempt discarded it inside a try/catch: a failing JS call reports itself on the task,
|
||||
/// not to the caller, so that catch never ran and the fault ended up as an unobserved task
|
||||
/// exception whenever the circuit was already gone.
|
||||
/// </remarks>
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "unregisterEscapeHandler", this.escapeHandlerId);
|
||||
await base.DisposeResourcesAsync();
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = this.JsRuntime.InvokeVoidAsync("unregisterEscapeHandler", this.escapeHandlerId).AsTask();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore JS cleanup errors while the dialog is being disposed.
|
||||
}
|
||||
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
|
||||
|
||||
@ -54,6 +54,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
[Inject]
|
||||
private MudTheme ColorTheme { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private CircuitStateService CircuitState { get; init; } = null!;
|
||||
|
||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||
|
||||
@ -110,7 +113,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
await this.SettingsManager.LoadSettings();
|
||||
|
||||
// Register this component with the message bus:
|
||||
this.MessageBus.RegisterComponent(this);
|
||||
this.MessageBus.RegisterComponent(this, this.CircuitState);
|
||||
this.MessageBus.ApplyFilters(this, [],
|
||||
[
|
||||
Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR,
|
||||
@ -234,7 +237,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
this.LoadNavItems();
|
||||
this.StateHasChanged();
|
||||
if (this.startupCompleted)
|
||||
_ = this.EnsureMandatoryInfosAcceptedAsync();
|
||||
this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after a configuration change");
|
||||
break;
|
||||
|
||||
case Event.COLOR_THEME_CHANGED:
|
||||
@ -281,7 +284,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
break;
|
||||
|
||||
case Event.STARTUP_PLUGIN_SYSTEM:
|
||||
_ = Task.Run(async () =>
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Set up the plugin system:
|
||||
if (PluginFactory.Setup())
|
||||
@ -336,7 +339,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
PluginFactory.SetUpHotReloading();
|
||||
await this.MessageBus.SendMessage<bool>(this, Event.STARTUP_COMPLETED);
|
||||
}
|
||||
});
|
||||
}).Observe($"{nameof(MainLayout)}: setting up the plugin system");
|
||||
break;
|
||||
|
||||
case Event.PLUGINS_RELOADED:
|
||||
@ -347,12 +350,12 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
if (this.startupCompleted)
|
||||
_ = this.EnsureMandatoryInfosAcceptedAsync();
|
||||
this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after a plugin reload");
|
||||
break;
|
||||
|
||||
case Event.STARTUP_COMPLETED:
|
||||
this.startupCompleted = true;
|
||||
_ = this.EnsureMandatoryInfosAcceptedAsync();
|
||||
this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after the startup");
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -399,11 +402,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
/// <summary>Refreshes navigation activity colors when a media import changes state.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
_ = this.InvokeAsync(() =>
|
||||
this.InvokeAsync(() =>
|
||||
{
|
||||
this.LoadNavItems();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}).Observe($"{nameof(MainLayout)}: refreshing the navigation after a media import change");
|
||||
}
|
||||
|
||||
private IEnumerable<NavBarItem> GetNavItems()
|
||||
|
||||
@ -39,10 +39,10 @@ public partial class Chat : MSGComponentBase
|
||||
|
||||
this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition;
|
||||
this.splitterSaveTimer.AutoReset = false;
|
||||
this.splitterSaveTimer.Elapsed += async (_, _) =>
|
||||
this.splitterSaveTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.Workspace.SplitterPosition = this.splitterPosition;
|
||||
await this.SettingsManager.StoreSettings();
|
||||
this.SettingsManager.StoreSettings().Observe($"{nameof(Chat)}: storing the splitter position");
|
||||
};
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
@ -42,7 +42,7 @@ public partial class Home : MSGComponentBase
|
||||
|
||||
// Read the last change content asynchronously
|
||||
// without blocking the UI thread:
|
||||
_ = this.ReadLastChangeAsync();
|
||||
this.ReadLastChangeAsync().Observe($"{nameof(Home)}: reading the last change");
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender)
|
||||
|
||||
@ -197,7 +197,7 @@ public partial class Information : MSGComponentBase
|
||||
|
||||
// Determine the Pandoc version may take some time, so we start it here
|
||||
// without waiting for the result:
|
||||
_ = this.DeterminePandocVersion();
|
||||
this.DeterminePandocVersion().Observe($"{nameof(Information)}: determining the Pandoc version");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -340,7 +340,7 @@ public partial class Information : MSGComponentBase
|
||||
this.vectorStoreRefreshCancellationTokenSource = new CancellationTokenSource();
|
||||
var cancellationToken = this.vectorStoreRefreshCancellationTokenSource.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
Task.Run(async () =>
|
||||
{
|
||||
const int MAX_TRIES = 12;
|
||||
for (var attempt = 0; attempt < MAX_TRIES; attempt++)
|
||||
@ -366,7 +366,7 @@ public partial class Information : MSGComponentBase
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
}, cancellationToken).Observe($"{nameof(Information)}: refreshing the vector store info");
|
||||
}
|
||||
|
||||
private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId)
|
||||
|
||||
@ -93,7 +93,7 @@ public partial class Plugins : MSGComponentBase
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
// Release the drop area again, so lower layers can catch dropped files:
|
||||
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES);
|
||||
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES).Observe($"{nameof(Plugins)}: releasing the drop area");
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ public partial class Writer : MSGComponentBase
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
|
||||
this.typeTimer.Elapsed += async (_, _) => await this.InvokeAsync(this.GetSuggestions);
|
||||
this.typeTimer.Elapsed += (_, _) => this.InvokeAsync(this.GetSuggestions).Observe($"{nameof(Writer)}: getting writing suggestions");
|
||||
this.typeTimer.AutoReset = false;
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
@ -6241,7 +6241,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2646845972"] = "Hinzufügen
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2728244552"] = "Zusätzliche API-Parameter"
|
||||
|
||||
-- Invalid JSON: Add the parameters in proper JSON formatting, e.g., "temperature": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2765821959"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. "temperature": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden."
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2765821959"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. \"temperature\": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden."
|
||||
|
||||
-- No models loaded or available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2810182573"] = "Keine Modelle geladen oder verfügbar."
|
||||
|
||||
@ -12,6 +12,7 @@ using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Security;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
@ -195,6 +196,13 @@ internal sealed class Program
|
||||
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
|
||||
builder.Services.AddScoped<NativeShareService>();
|
||||
builder.Services.AddScoped<PluginShareService>();
|
||||
|
||||
//
|
||||
// One circuit state per circuit, and the handler which keeps it up to date. Both are scoped,
|
||||
// because the circuit is the scope: every browser window gets its own pair.
|
||||
//
|
||||
builder.Services.AddScoped<CircuitStateService>();
|
||||
builder.Services.AddScoped<CircuitHandler, AIStudioCircuitHandler>();
|
||||
|
||||
// ReSharper disable AccessToDisposedClosure
|
||||
builder.Services.AddHostedService<RustService>(_ => rust);
|
||||
@ -243,7 +251,22 @@ internal sealed class Program
|
||||
// Get a program logger:
|
||||
var programLogger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
programLogger.LogInformation("Starting the AI Studio server.");
|
||||
|
||||
|
||||
//
|
||||
// Observe tasks whose exceptions nobody awaited. We register this before the server starts:
|
||||
// otherwise, everything the startup does — the plugin system, the first message bus traffic —
|
||||
// would fault outside of this handler. The sender of such a task says nothing about where it
|
||||
// came from, which is why we log each inner exception with its own stack trace.
|
||||
//
|
||||
TaskScheduler.UnobservedTaskException += (sender, taskArgs) =>
|
||||
{
|
||||
programLogger.LogError(taskArgs.Exception, $"Unobserved task exception by sender '{sender ?? "n/a"}'.");
|
||||
foreach (var innerException in taskArgs.Exception.Flatten().InnerExceptions)
|
||||
programLogger.LogError(innerException, $"Unobserved task exception detail: {innerException.GetType().FullName}.");
|
||||
|
||||
taskArgs.SetObserved();
|
||||
};
|
||||
|
||||
// Store the service provider (DI). We need it later for some classes,
|
||||
// which are not part of the request pipeline:
|
||||
SERVICE_PROVIDER = app.Services;
|
||||
@ -295,13 +318,7 @@ internal sealed class Program
|
||||
await encryptionInitializer;
|
||||
await rust.AppIsReady();
|
||||
programLogger.LogInformation("The AI Studio server is ready.");
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (sender, taskArgs) =>
|
||||
{
|
||||
programLogger.LogError(taskArgs.Exception, $"Unobserved task exception by sender '{sender ?? "n/a"}'.");
|
||||
taskArgs.SetObserved();
|
||||
};
|
||||
|
||||
|
||||
await serverTask;
|
||||
|
||||
RUST_SERVICE.Dispose();
|
||||
|
||||
@ -131,7 +131,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
||||
await CheckpointChatAsync(state, force: true);
|
||||
await this.NotifyChangedAsync(state);
|
||||
|
||||
_ = Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning);
|
||||
//
|
||||
// Unwrap matters here: StartNew with an async delegate hands back a task which completes as soon
|
||||
// as the generation started, wrapping the task which does the actual work. Watching the outer one
|
||||
// would tell us nothing about how the generation itself ended.
|
||||
//
|
||||
Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning).Unwrap().Observe($"{nameof(AIJobService)}: running a chat generation");
|
||||
return state.Snapshot;
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,121 @@
|
||||
using AIStudio.Assistants;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class JsRuntimeExtensions
|
||||
{
|
||||
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(JsRuntimeExtensions));
|
||||
|
||||
public static async Task GenerateAndShowDiff(this IJSRuntime jsRuntime, string text1, string text2)
|
||||
{
|
||||
await jsRuntime.InvokeVoidAsync("generateDiff", text1, text2, AssistantLowerBase.RESULT_DIV_ID, AssistantLowerBase.BEFORE_RESULT_DIV_ID);
|
||||
}
|
||||
|
||||
|
||||
public static async Task ClearDiv(this IJSRuntime jsRuntime, string divId)
|
||||
{
|
||||
await jsRuntime.InvokeVoidAsync("clearDiv", divId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a JavaScript function which returns nothing, and tolerates a circuit which is already gone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blazor cannot issue JS interop calls once the browser connection of a circuit is gone. That happens
|
||||
/// during every reload and while a component gets disposed, so the failure is expected rather than
|
||||
/// exceptional. Discarding such a call is not an option, though: the discarded task keeps the fault
|
||||
/// until the finalizer reports it as an unobserved task exception, without any hint at its origin.
|
||||
/// This method is the one place which knows how to await such a call and what to do with its failure.
|
||||
/// </remarks>
|
||||
/// <param name="jsRuntime">The JS runtime to call.</param>
|
||||
/// <param name="identifier">The name of the JavaScript function.</param>
|
||||
/// <param name="args">The arguments for the JavaScript function.</param>
|
||||
/// <returns>True when the browser ran the function. Callers which remember what they told the browser
|
||||
/// must check this: a call which never arrived leaves the browser in its previous state.</returns>
|
||||
public static async ValueTask<bool> TryInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
await jsRuntime.InvokeVoidAsync(identifier, args);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LogInvocationFailure(exception, identifier);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a JavaScript function which returns nothing, unless the circuit is known to be disconnected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Prefer this over the variant without a circuit state wherever the caller knows its circuit. While a
|
||||
/// browser connection is gone, every single call would otherwise throw, which is needless work for
|
||||
/// something we already know cannot succeed — a component of a disconnected circuit which keeps
|
||||
/// rendering would produce one such exception per render.
|
||||
/// </remarks>
|
||||
/// <param name="jsRuntime">The JS runtime to call.</param>
|
||||
/// <param name="circuitState">The circuit of the caller.</param>
|
||||
/// <param name="identifier">The name of the JavaScript function.</param>
|
||||
/// <param name="args">The arguments for the JavaScript function.</param>
|
||||
/// <returns>True when the browser ran the function, false when it was skipped or failed.</returns>
|
||||
public static async ValueTask<bool> TryInvokeVoidAsync(this IJSRuntime jsRuntime, CircuitStateService circuitState, string identifier, params object?[]? args)
|
||||
{
|
||||
if (!circuitState.IsConnected)
|
||||
{
|
||||
LOGGER.LogDebug("The JS call '{Identifier}' was skipped because the browser connection of the circuit '{CircuitId}' is down.", identifier, circuitState.CircuitId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await jsRuntime.TryInvokeVoidAsync(identifier, args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a function of a JavaScript module which returns nothing, and tolerates a circuit which is
|
||||
/// already gone. See the remarks on the JS runtime variant of this method.
|
||||
/// </summary>
|
||||
/// <param name="module">The JavaScript module to call.</param>
|
||||
/// <param name="identifier">The name of the function inside the module.</param>
|
||||
/// <param name="args">The arguments for the function.</param>
|
||||
/// <returns>True when the browser ran the function, false when it failed.</returns>
|
||||
public static async ValueTask<bool> TryInvokeVoidAsync(this IJSObjectReference module, string identifier, params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
await module.InvokeVoidAsync(identifier, args);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LogInvocationFailure(exception, identifier);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogInvocationFailure(Exception exception, string identifier)
|
||||
{
|
||||
switch (exception)
|
||||
{
|
||||
//
|
||||
// The circuit is disconnected or disposed, or the call was canceled while it was on its way.
|
||||
// None of this is a defect: it is what a reload, a lost connection, or a disposed component
|
||||
// looks like from here.
|
||||
//
|
||||
case JSDisconnectedException:
|
||||
case ObjectDisposedException:
|
||||
case OperationCanceledException:
|
||||
LOGGER.LogDebug("The JS call '{Identifier}' was not completed because the browser connection was gone: {Reason}", identifier, exception.Message);
|
||||
break;
|
||||
|
||||
// The call reached the browser, but failed there. That is worth knowing about:
|
||||
case JSException:
|
||||
LOGGER.LogWarning(exception, "The JS call '{Identifier}' failed in the browser.", identifier);
|
||||
break;
|
||||
|
||||
default:
|
||||
LOGGER.LogError(exception, "The JS call '{Identifier}' failed unexpectedly.", identifier);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
// ReSharper disable RedundantRecordClassKeyword
|
||||
|
||||
@ -11,6 +13,7 @@ public sealed class MessageBus
|
||||
|
||||
private readonly ConcurrentDictionary<IMessageBusReceiver, ComponentBase[]> componentFilters = new();
|
||||
private readonly ConcurrentDictionary<IMessageBusReceiver, Event[]> componentEvents = new();
|
||||
private readonly ConcurrentDictionary<IMessageBusReceiver, CircuitStateService> receiverCircuits = new();
|
||||
private readonly ConcurrentDictionary<Event, ConcurrentQueue<Message>> deferredMessages = new();
|
||||
private readonly ConcurrentQueue<Message> messageQueue = new();
|
||||
private readonly SemaphoreSlim sendingSemaphore = new(1, 1);
|
||||
@ -39,16 +42,53 @@ public sealed class MessageBus
|
||||
this.componentEvents[receiver] = events.ToArray();
|
||||
}
|
||||
|
||||
public void RegisterComponent(IMessageBusReceiver receiver)
|
||||
/// <summary>
|
||||
/// Registers a receiver at the bus.
|
||||
/// </summary>
|
||||
/// <param name="receiver">That's you, the receiver.</param>
|
||||
/// <param name="circuitState">The circuit this receiver belongs to. Components hand over their circuit
|
||||
/// so the bus can let them go when that circuit ends. Services which live longer than any circuit,
|
||||
/// such as hosted services, hand over nothing.</param>
|
||||
public void RegisterComponent(IMessageBusReceiver receiver, CircuitStateService? circuitState = null)
|
||||
{
|
||||
this.componentFilters.TryAdd(receiver, []);
|
||||
this.componentEvents.TryAdd(receiver, []);
|
||||
|
||||
if (circuitState is not null)
|
||||
this.receiverCircuits[receiver] = circuitState;
|
||||
}
|
||||
|
||||
|
||||
public void Unregister(IMessageBusReceiver receiver)
|
||||
{
|
||||
this.componentFilters.TryRemove(receiver, out _);
|
||||
this.componentEvents.TryRemove(receiver, out _);
|
||||
this.receiverCircuits.TryRemove(receiver, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all receivers which belong to one circuit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The circuit handler calls this when a circuit ends. Components deregister themselves when they get
|
||||
/// disposed, but a circuit which was retained and then dropped does not give all of them that chance.
|
||||
/// Since the bus holds a strong reference to every receiver, those leftovers would stay and would be
|
||||
/// served forever.
|
||||
/// </remarks>
|
||||
/// <param name="circuitState">The circuit whose receivers must go.</param>
|
||||
/// <returns>The number of removed receivers.</returns>
|
||||
public int UnregisterCircuit(CircuitStateService circuitState)
|
||||
{
|
||||
var numRemovedReceivers = 0;
|
||||
foreach (var (receiver, receiverCircuit) in this.receiverCircuits)
|
||||
{
|
||||
if (!ReferenceEquals(receiverCircuit, circuitState))
|
||||
continue;
|
||||
|
||||
this.Unregister(receiver);
|
||||
numRemovedReceivers++;
|
||||
}
|
||||
|
||||
return numRemovedReceivers;
|
||||
}
|
||||
|
||||
private record class Message(ComponentBase? SendingComponent, Event TriggeredEvent, object? Data);
|
||||
@ -71,7 +111,7 @@ public sealed class MessageBus
|
||||
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);
|
||||
_ = DeliverMessage(receiver, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -85,6 +125,38 @@ public sealed class MessageBus
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands one message to one receiver and observes how that went.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bus must not wait for a receiver, since one slow receiver would hold up everybody else. Not
|
||||
/// waiting is not the same as not caring, though: a receiver whose circuit is gone fails with a
|
||||
/// disconnect or disposal exception, and nobody would ever see where it came from. Such a task
|
||||
/// carries its fault until the finalizer reports it as an unobserved task exception — naming a task
|
||||
/// type instead of the receiver and the event. This is where we give those failures a name.
|
||||
/// </remarks>
|
||||
/// <param name="receiver">The receiver of the message.</param>
|
||||
/// <param name="message">The message to deliver.</param>
|
||||
private static async Task DeliverMessage(IMessageBusReceiver receiver, Message message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data);
|
||||
}
|
||||
catch (Exception exception) when (exception is JSDisconnectedException or ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
//
|
||||
// Expected whenever the browser connection of a receiver is gone: the app keeps circuits
|
||||
// of reloaded or sleeping windows around, and their components still receive events.
|
||||
//
|
||||
LOG?.LogDebug("The receiver '{ReceiverName}' did not process the event '{Event}' because its circuit was gone: {Reason}", receiver.GetType().Name, message.TriggeredEvent, exception.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG?.LogError(exception, "The receiver '{ReceiverName}' failed while processing the event '{Event}'.", receiver.GetType().Name, message.TriggeredEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public Task SendError(DataErrorMessage dataErrorMessage) => this.SendMessage(null, Event.SHOW_ERROR, dataErrorMessage);
|
||||
|
||||
public Task SendWarning(DataWarningMessage dataWarningMessage) => this.SendMessage(null, Event.SHOW_WARNING, dataWarningMessage);
|
||||
|
||||
@ -50,7 +50,7 @@ public static partial class PluginFactory
|
||||
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
|
||||
try
|
||||
{
|
||||
HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => _ = ReloadPluginsAsync();
|
||||
HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => ReloadPluginsAsync().Observe($"{nameof(PluginFactory)}: hot reloading plugins");
|
||||
|
||||
HOT_RELOAD_WATCHER.IncludeSubdirectories = true;
|
||||
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Follows the life of one circuit, so the rest of the app knows when its browser is unreachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app keeps disconnected circuits for a long time on purpose, cf. the retention settings in
|
||||
/// Program.cs. That is what lets a user return to a working app after the machine woke up — but it also
|
||||
/// means that the components of reloaded or sleeping windows stay alive and keep receiving events. They
|
||||
/// may keep working: everything they do on the server is fine. Only JavaScript interop is impossible
|
||||
/// while the connection is gone. So this handler does two things, and deliberately nothing more:
|
||||
/// it publishes the connection state, and it cleans up once a circuit is truly over.
|
||||
/// </remarks>
|
||||
public sealed class AIStudioCircuitHandler(CircuitStateService circuitState, MessageBus messageBus, ILogger<AIStudioCircuitHandler> logger)
|
||||
: CircuitHandler
|
||||
{
|
||||
#region Overrides of CircuitHandler
|
||||
|
||||
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
|
||||
{
|
||||
circuitState.AssignCircuit(circuit.Id);
|
||||
logger.LogInformation("The circuit '{CircuitId}' was opened.", circuit.Id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
|
||||
{
|
||||
circuitState.MarkAsConnected();
|
||||
logger.LogInformation("The browser connection of the circuit '{CircuitId}' is up.", circuit.Id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
|
||||
{
|
||||
circuitState.MarkAsDisconnected();
|
||||
logger.LogInformation("The browser connection of the circuit '{CircuitId}' is down. Its JavaScript interop is paused until it returns.", circuit.Id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
|
||||
{
|
||||
circuitState.MarkAsDisconnected();
|
||||
|
||||
//
|
||||
// The components of this circuit will not come back, so nobody would ever deregister them:
|
||||
// Blazor disposes components of a retained circuit without giving them a chance to run their
|
||||
// disposal in every case. Without this, the message bus would keep and serve them forever.
|
||||
//
|
||||
var numRemovedReceivers = messageBus.UnregisterCircuit(circuitState);
|
||||
logger.LogInformation("The circuit '{CircuitId}' was closed. Removed {NumReceivers} message bus receiver(s) of that circuit.", circuit.Id, numRemovedReceivers);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
46
app/MindWork AI Studio/Tools/Services/CircuitStateService.cs
Normal file
46
app/MindWork AI Studio/Tools/Services/CircuitStateService.cs
Normal file
@ -0,0 +1,46 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Knows whether the browser connection of one circuit is currently up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is one instance of this service per circuit, i.e. per browser window. It exists because the app
|
||||
/// keeps disconnected circuits around for a long time, cf. the retention settings in Program.cs: after a
|
||||
/// reload or while the machine sleeps, the components of the old circuit are still alive and still receive
|
||||
/// events. They may do their work as before — only JavaScript interop is impossible while the connection
|
||||
/// is gone. This is what tells them apart. Only the circuit handler changes this state.
|
||||
/// </remarks>
|
||||
public sealed class CircuitStateService
|
||||
{
|
||||
private volatile bool isConnected = true;
|
||||
|
||||
/// <summary>
|
||||
/// True, as long as the browser of this circuit is reachable, and thus JS interop is possible.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This starts as true: a circuit is created for a connected browser, and the handler reports the
|
||||
/// first connection only afterwards. Starting as false would block the interop of the first render.
|
||||
/// </remarks>
|
||||
public bool IsConnected => this.isConnected;
|
||||
|
||||
/// <summary>
|
||||
/// The ID of this circuit, for logging purposes. It is "n/a" until the circuit was opened.
|
||||
/// </summary>
|
||||
public string CircuitId { get; private set; } = "n/a";
|
||||
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the circuit was opened.
|
||||
/// </summary>
|
||||
/// <param name="circuitId">The ID of the opened circuit.</param>
|
||||
public void AssignCircuit(string circuitId) => this.CircuitId = circuitId;
|
||||
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the browser connection was established or restored.
|
||||
/// </summary>
|
||||
public void MarkAsConnected() => this.isConnected = true;
|
||||
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the browser connection was lost or the circuit ended.
|
||||
/// </summary>
|
||||
public void MarkAsDisconnected() => this.isConnected = false;
|
||||
}
|
||||
@ -173,7 +173,7 @@ public sealed class MediaTranscriptionService(
|
||||
}
|
||||
|
||||
this.UpdateImportState(target, Path.GetFileName(mediaPaths[0]), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED);
|
||||
_ = Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat));
|
||||
Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat)).Observe($"{nameof(MediaTranscriptionService)}: running an attachment batch");
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -191,7 +191,7 @@ public sealed class MediaTranscriptionService(
|
||||
}
|
||||
|
||||
this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED);
|
||||
_ = Task.Run(() => this.RunTextImportAsync(mediaPath, target));
|
||||
Task.Run(() => this.RunTextImportAsync(mediaPath, target)).Observe($"{nameof(MediaTranscriptionService)}: running a text import");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -72,8 +72,8 @@ public sealed class RustAvailabilityMonitorService : BackgroundService, IMessage
|
||||
// be a transient issue.
|
||||
//
|
||||
|
||||
_ = this.VerifyRustAvailability();
|
||||
_ = this.VerifyRustAvailability();
|
||||
this.VerifyRustAvailability().Observe($"{nameof(RustAvailabilityMonitorService)}: verifying the Rust availability");
|
||||
this.VerifyRustAvailability().Observe($"{nameof(RustAvailabilityMonitorService)}: verifying the Rust availability");
|
||||
}
|
||||
|
||||
if (numEvents <= UNAVAILABLE_EVENT_THRESHOLD)
|
||||
|
||||
@ -26,7 +26,12 @@ public sealed partial class RustService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fire-and-forget the log event to avoid blocking:
|
||||
//
|
||||
// Fire-and-forget the log event to avoid blocking. This is the one place which deliberately
|
||||
// discards its task instead of observing it: observing means logging the failure, and logging
|
||||
// means sending another log event through this very method. A broken connection to Rust would
|
||||
// feed itself. The unobserved task exception handler in Program.cs remains the safety net here.
|
||||
//
|
||||
var request = new LogEventRequest(timestamp, level, category, message, exception, stackTrace);
|
||||
_ = this.http.PostAsJsonAsync("/log/event", request, this.jsonRustSerializerOptions);
|
||||
}
|
||||
|
||||
53
app/MindWork AI Studio/Tools/TaskExtensions.cs
Normal file
53
app/MindWork AI Studio/Tools/TaskExtensions.cs
Normal file
@ -0,0 +1,53 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class TaskExtensions
|
||||
{
|
||||
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(TaskExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// Lets a task run on its own, but keeps an eye on how it ends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this wherever a task is started without awaiting it. Discarding one instead means that nobody
|
||||
/// ever looks at its outcome: the task carries its exception until the garbage collector finalizes it,
|
||||
/// and only then does it show up as an unobserved task exception — naming a task type, without any
|
||||
/// hint at what was running. Around a circuit which is gone, that is the common case: components of a
|
||||
/// reloaded or sleeping window still receive events and still schedule work.
|
||||
/// </remarks>
|
||||
/// <param name="task">The task to watch.</param>
|
||||
/// <param name="context">What this task was doing, for the log entry.</param>
|
||||
public static void Observe(this Task task, string context)
|
||||
{
|
||||
task.ContinueWith(finishedTask =>
|
||||
LogFailure(finishedTask.Exception, context),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private static void LogFailure(AggregateException? exception, string context)
|
||||
{
|
||||
if (exception is null)
|
||||
return;
|
||||
|
||||
foreach (var innerException in exception.Flatten().InnerExceptions)
|
||||
{
|
||||
switch (innerException)
|
||||
{
|
||||
//
|
||||
// The browser connection is gone, or the component was disposed while its work was still
|
||||
// on its way. Neither is a defect: it is what a reload or a closed window looks like.
|
||||
//
|
||||
case JSDisconnectedException:
|
||||
case ObjectDisposedException:
|
||||
case OperationCanceledException:
|
||||
LOGGER.LogDebug("Background work '{Context}' stopped because its circuit was gone: {Reason}", context, innerException.Message);
|
||||
break;
|
||||
|
||||
default:
|
||||
LOGGER.LogError(innerException, "Background work '{Context}' failed.", context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
- Improved the safety of plugin symbols: AI Studio now shows the symbol of a plugin in isolation, so nothing inside a symbol can reach the rest of the app.
|
||||
- Improved how much memory AI Studio needs. Working with large documents used to grow the app to several gigabytes, and on macOS that memory was never handed back. AI Studio now stays at a fraction of that and returns memory to your system. This matters most on devices with little memory, such as a Raspberry Pi.
|
||||
- Improved the preview for large documents. It now shows you the beginning of your document instead of loading all of it, so the dialog opens right away. Your complete document still goes to the AI.
|
||||
- Improved how AI Studio deals with rare internal hiccups. When the app window reloads, or when it briefly loses the connection to its own user interface, work which was still running in the background is now ended properly instead of leaving errors behind.
|
||||
- Fixed AI Studio reading a document to the end even after you closed its preview. Closing the dialog now stops that work immediately.
|
||||
- Fixed AI Studio holding on to finished chats, presentation images, and plugin data. It releases them now, so memory no longer grows the longer you keep the app running.
|
||||
- Fixed assistants handing an outdated result to the chat. When you sent several results without opening the chat in between, you now receive the one you sent last.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user