From 574fa4a3850a901dfd49ecad28a25cc259344e84 Mon Sep 17 00:00:00 2001 From: hart_s3 Date: Thu, 9 Jul 2026 14:00:00 +0200 Subject: [PATCH] Introduce session persistence and reconnect recovery mechanisms. --- app/MindWork AI Studio/App.razor | 3 + .../Components/ChatComponent.razor.cs | 101 +++++++++- .../Components/ChatComposerState.cs | 9 +- .../Layout/MainLayout.razor.cs | 39 +++- app/MindWork AI Studio/Pages/Chat.razor.cs | 34 +++- app/MindWork AI Studio/Program.cs | 4 +- app/MindWork AI Studio/Routes.razor | 4 +- app/MindWork AI Studio/Routes.razor.cs | 30 ++- .../Tools/Services/ChatPageSessionService.cs | 82 ++++++++ .../Services/ReconnectRecoveryService.cs | 28 +++ app/MindWork AI Studio/wwwroot/boot.js | 176 ++++++++++++++---- 11 files changed, 467 insertions(+), 43 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Services/ChatPageSessionService.cs create mode 100644 app/MindWork AI Studio/Tools/Services/ReconnectRecoveryService.cs diff --git a/app/MindWork AI Studio/App.razor b/app/MindWork AI Studio/App.razor index 7df24793..e05a7749 100644 --- a/app/MindWork AI Studio/App.razor +++ b/app/MindWork AI Studio/App.razor @@ -21,6 +21,9 @@ + diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 06b6fb92..6cd462e3 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -4,6 +4,7 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AIJobs; +using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -54,6 +55,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable [Inject] private AIJobService AIJobService { get; init; } = null!; + [Inject] + private ChatPageSessionService ChatPageSessionService { get; init; } = null!; + private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); @@ -97,7 +101,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private string UserInput { get => this.ComposerState.UserInput; - set => this.ComposerState.SetUserInput(value); + set + { + this.ComposerState.SetUserInput(value); + this.CheckpointChatPageState(); + } } #region Overrides of ComponentBase @@ -232,6 +240,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.Logger.LogInformation($"The loading of the chat '{this.loadChat.ChatId}' was deferred and will be loaded now."); } + if (deferredContent is null && string.IsNullOrWhiteSpace(deferredInput) && deferredLoading == default) + this.RestoreChatPageStateIfAvailable(); + // // When for whatever reason we have a chat thread, we have to // ensure that the corresponding workspace id is set and the @@ -244,6 +255,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SelectProviderWhenLoadingChat(); await this.SyncForegroundChatAsync(); await base.OnInitializedAsync(); + this.CheckpointChatPageState(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -300,6 +312,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.previousInputForbidden = inputForbidden; await base.OnAfterRenderAsync(firstRender); + this.CheckpointChatPageState(); } protected override async Task OnParametersSetAsync() @@ -315,6 +328,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.ApplyLoadedChatParameterAsync(); await this.SyncForegroundChatAsync(); await base.OnParametersSetAsync(); + this.CheckpointChatPageState(); } #endregion @@ -375,6 +389,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.currentChatThreadId = chatThreadId; this.currentWorkspaceId = workspaceId; this.PublishWorkspaceNameIfChanged(loadedWorkspaceName); + this.CheckpointChatPageState(); } private void ClearWorkspaceHeaderState() @@ -382,6 +397,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.currentChatThreadId = Guid.Empty; this.currentWorkspaceId = Guid.Empty; this.PublishWorkspaceNameIfChanged(string.Empty); + this.CheckpointChatPageState(); } private void PublishWorkspaceNameIfChanged(string workspaceName) @@ -394,6 +410,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.currentWorkspaceName = workspaceName; this.WorkspaceName(this.currentWorkspaceName); + this.CheckpointChatPageState(); } private async Task RefreshRenamedWorkspaceHeaderAsync(Guid workspaceId) @@ -417,6 +434,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.currentChatThreadId = chatThreadId; this.currentWorkspaceId = workspaceId; this.PublishWorkspaceNameIfChanged(loadedWorkspaceName); + this.CheckpointChatPageState(); } private async Task SyncForegroundChatAsync() @@ -467,6 +485,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.DataSourceOptions = chatDefaultOptions; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(chatDefaultOptions); + this.CheckpointChatPageState(); } private async Task ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange() @@ -479,6 +498,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { this.earlyDataSourceOptions = updatedStandardOptions; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions); + this.CheckpointChatPageState(); return; } @@ -488,6 +508,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SetCurrentDataSourceOptions(updatedStandardOptions); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions, this.ChatThread.AISelectedDataSources); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + this.CheckpointChatPageState(); } private static bool DataSourceOptionsAreEqual(DataSourceOptions left, DataSourceOptions right) @@ -521,7 +542,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { this.currentProfile = this.SettingsManager.GetProfileById(profile.Id); if(this.ChatThread is null) + { + this.CheckpointChatPageState(); return; + } this.ChatThread = this.ChatThread with { @@ -529,6 +553,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable }; await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + this.CheckpointChatPageState(); } private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate) @@ -541,7 +566,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); if(this.ChatThread is null) + { + this.CheckpointChatPageState(); return; + } await this.StartNewChat(true); } @@ -582,6 +610,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ComposerState.ApplyTemplate(this.currentChatTemplate); await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange(); + this.CheckpointChatPageState(); } private IReadOnlyList GetAgentSelectedDataSources() @@ -614,6 +643,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable } else this.earlyDataSourceOptions = updatedOptions; + + this.CheckpointChatPageState(); } private bool IsInputForbidden() @@ -637,6 +668,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.hasUnsavedChanges = true; this.ComposerState.MarkUserDraft(); + this.CheckpointChatPageState(); var key = keyEvent.Code.ToLowerInvariant(); // Was the enter key (either enter or numpad enter) pressed? @@ -670,6 +702,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync("formatChatInputMarkdown", CHAT_INPUT_ID, formatType)); this.hasUnsavedChanges = true; + this.CheckpointChatPageState(); } private void ComposerAttachmentsChanged(HashSet attachments) @@ -679,6 +712,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ComposerState.MarkUserDraft(); this.hasUnsavedChanges = true; + this.CheckpointChatPageState(); } private async Task SendMessage(bool reuseLastUserPrompt = false) @@ -813,6 +847,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable }); await this.SyncForegroundChatAsync(); + this.CheckpointChatPageState(); this.StateHasChanged(); } @@ -841,6 +876,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); this.hasUnsavedChanges = false; + this.CheckpointChatPageState(); } private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false) @@ -950,6 +986,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SyncForegroundChatAsync(); this.MarkCurrentChatAsLoadedParameter(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + this.CheckpointChatPageState(); } private async Task MoveChatToWorkspace() @@ -994,6 +1031,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SaveThread(); await this.SyncWorkspaceHeaderWithChatThreadAsync(); + this.CheckpointChatPageState(); } private async Task LoadedChatChanged(bool notifyParent = true) @@ -1029,6 +1067,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.scrollRenderCountdown = 2; } + this.CheckpointChatPageState(); this.StateHasChanged(); } @@ -1043,11 +1082,15 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + this.CheckpointChatPageState(); } private async Task SelectProviderWhenLoadingChat() { var chatProvider = this.ChatThread?.SelectedProvider; + if (string.IsNullOrWhiteSpace(chatProvider) && this.Provider != AIStudio.Settings.Provider.NONE) + chatProvider = this.Provider.Id; + var chatProfile = this.ChatThread?.SelectedProfile; var chatChatTemplate = this.ChatThread?.SelectedChatTemplate; @@ -1062,6 +1105,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Try to select the chat template: if (!string.IsNullOrWhiteSpace(chatChatTemplate)) this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatChatTemplate); + + this.CheckpointChatPageState(); } private async Task ToggleWorkspaceOverlay() @@ -1077,6 +1122,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.Remove(block); this.hasUnsavedChanges = true; await this.SaveThread(); + this.CheckpointChatPageState(); this.StateHasChanged(); } @@ -1090,6 +1136,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.Remove(aiBlock, removeForRegenerate: true); this.hasUnsavedChanges = true; + this.CheckpointChatPageState(); this.StateHasChanged(); await this.SendMessage(reuseLastUserPrompt: true); @@ -1112,6 +1159,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ChatThread.Remove(block); this.ChatThread.Remove(lastBlockContent); this.hasUnsavedChanges = true; + this.CheckpointChatPageState(); this.StateHasChanged(); return Task.CompletedTask; @@ -1128,6 +1176,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.RestoreComposerFromTextBlock(textBlock); this.ChatThread.Remove(block); this.hasUnsavedChanges = true; + this.CheckpointChatPageState(); this.StateHasChanged(); return Task.CompletedTask; @@ -1136,6 +1185,50 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private void RestoreComposerFromTextBlock(ContentText textBlock) { this.ComposerState.RestoreFromTextBlock(textBlock); + this.CheckpointChatPageState(); + } + + private void RestoreChatPageStateIfAvailable() + { + var snapshot = this.ChatPageSessionService.GetComponentSnapshot(); + if (snapshot is null) + return; + + this.ChatThread = snapshot.ChatThread; + if (this.ChatThread is not null) + this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread; + + this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(snapshot.ProviderId); + this.currentProfile = this.SettingsManager.GetProfileById(snapshot.CurrentProfileId); + this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(snapshot.CurrentChatTemplateId); + this.ComposerState.Restore(snapshot.UserInput, snapshot.FileAttachments, snapshot.HasUserDraft); + this.hasUnsavedChanges = snapshot.HasUnsavedChanges; + this.autoSaveEnabled = snapshot.AutoSaveEnabled; + this.earlyDataSourceOptions = snapshot.EarlyDataSourceOptions.CreateCopy(); + this.lastAppliedStandardDataSourceOptions = snapshot.LastAppliedStandardDataSourceOptions.CreateCopy(); + this.currentWorkspaceName = snapshot.CurrentWorkspaceName; + this.currentWorkspaceId = snapshot.CurrentWorkspaceId; + this.currentChatThreadId = snapshot.CurrentChatThreadId; + this.MarkCurrentChatAsLoadedParameter(); + } + + private void CheckpointChatPageState() + { + this.ChatPageSessionService.StoreComponentSnapshot(new ChatPageComponentSnapshot( + this.ChatThread, + this.Provider.Id, + this.currentProfile.Id, + this.currentChatTemplate.Id, + this.ComposerState.UserInput, + this.ComposerState.HasUserDraft, + this.ComposerState.FileAttachments.ToArray(), + this.hasUnsavedChanges, + this.autoSaveEnabled, + this.earlyDataSourceOptions, + this.lastAppliedStandardDataSourceOptions, + this.currentWorkspaceName, + this.currentWorkspaceId, + this.currentChatThreadId)); } #region Overrides of MSGComponentBase @@ -1155,6 +1248,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.hasUnsavedChanges = true; if(this.autoSaveEnabled) await this.SaveThread(); + this.CheckpointChatPageState(); break; case Event.WORKSPACE_RENAMED: @@ -1165,6 +1259,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable case Event.CONFIGURATION_CHANGED: case Event.PLUGINS_RELOADED: await this.RefreshChatSelectionsAfterConfigurationChange(); + this.CheckpointChatPageState(); this.StateHasChanged(); break; @@ -1180,6 +1275,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.previousInputForbidden = true; } + this.CheckpointChatPageState(); this.StateHasChanged(); } break; @@ -1216,8 +1312,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable } await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); + this.CheckpointChatPageState(); this.Dispose(); } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/ChatComposerState.cs b/app/MindWork AI Studio/Components/ChatComposerState.cs index a1565611..25a114f4 100644 --- a/app/MindWork AI Studio/Components/ChatComposerState.cs +++ b/app/MindWork AI Studio/Components/ChatComposerState.cs @@ -62,4 +62,11 @@ public sealed class ChatComposerState this.ReplaceFileAttachments(textBlock.FileAttachments); this.HasUserDraft = true; } -} \ No newline at end of file + + public void Restore(string? userInput, IEnumerable fileAttachments, bool hasUserDraft) + { + this.UserInput = userInput ?? string.Empty; + this.ReplaceFileAttachments(fileAttachments); + this.HasUserDraft = hasUserDraft; + } +} diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index b7f9aae1..ef1de4ab 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -40,12 +40,18 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [Inject] private NavigationManager NavigationManager { get; init; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; init; } = null!; [Inject] private ILogger Logger { get; init; } = null!; [Inject] private MudTheme ColorTheme { get; init; } = null!; + + [Inject] + private ReconnectRecoveryService ReconnectRecoveryService { get; init; } = null!; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; @@ -64,6 +70,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private bool startupCompleted; private bool settingsWriteProtectionWarningShown; private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1); + private readonly string reconnectRecoveryHandlerId = $"reconnect-recovery-{Guid.NewGuid()}"; + private DotNetObjectReference? dotNetReference; private IReadOnlyCollection navItems = []; @@ -130,6 +138,17 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await base.OnInitializedAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + this.dotNetReference = DotNetObjectReference.Create(this); + await this.JsRuntime.InvokeVoidAsync("registerReconnectRecovery", this.reconnectRecoveryHandlerId, this.dotNetReference); + } + + await base.OnAfterRenderAsync(firstRender); + } + #endregion private void ShowSettingsWriteProtectionWarning() @@ -519,14 +538,32 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await this.SettingsManager.StoreSettings(); } + + [JSInvokable] + public Task HandleReconnectRecoveryAsync() + { + this.Logger.LogInformation("The Blazor circuit reconnected. Requesting route subtree recovery."); + this.ReconnectRecoveryService.NotifyRecovered(); + return Task.CompletedTask; + } #region Implementation of IDisposable public void Dispose() { this.MessageBus.Unregister(this); + try + { + _ = this.JsRuntime.InvokeVoidAsync("unregisterReconnectRecovery", this.reconnectRecoveryHandlerId).AsTask(); + } + catch + { + // ignore + } + + this.dotNetReference?.Dispose(); this.mandatoryInfoDialogSemaphore.Dispose(); } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 6f3d2fbd..2af43706 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -2,6 +2,7 @@ using AIStudio.Chat; using AIStudio.Components; using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; @@ -19,6 +20,9 @@ public partial class Chat : MSGComponentBase [Inject] private IDialogService DialogService { get; init; } = null!; + + [Inject] + private ChatPageSessionService ChatPageSessionService { get; init; } = null!; private ChatThread? chatThread; private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE; @@ -38,6 +42,7 @@ public partial class Chat : MSGComponentBase this.ApplyFilters([], [ Event.WORKSPACE_TOGGLE_OVERLAY ]); this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition; + this.RestoreLayoutSnapshotIfAvailable(); this.splitterSaveTimer.AutoReset = false; this.splitterSaveTimer.Elapsed += async (_, _) => { @@ -46,6 +51,7 @@ public partial class Chat : MSGComponentBase }; await base.OnInitializedAsync(); + this.CheckpointLayoutState(); } #endregion @@ -68,6 +74,7 @@ public partial class Chat : MSGComponentBase { this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible = !this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible; await this.SettingsManager.StoreSettings(); + this.CheckpointLayoutState(); } private void SplitterChanged(double position) @@ -75,11 +82,13 @@ public partial class Chat : MSGComponentBase this.splitterPosition = position; this.splitterSaveTimer.Stop(); this.splitterSaveTimer.Start(); + this.CheckpointLayoutState(); } private void ToggleWorkspacesOverlay() { this.workspaceOverlayVisible = !this.workspaceOverlayVisible; + this.CheckpointLayoutState(); this.StateHasChanged(); } @@ -88,6 +97,7 @@ public partial class Chat : MSGComponentBase private void UpdateWorkspaceName(string workspaceName) { this.currentWorkspaceName = workspaceName; + this.CheckpointLayoutState(); this.StateHasChanged(); } @@ -118,6 +128,28 @@ public partial class Chat : MSGComponentBase return; await this.workspaces.ToggleSearchAsync(); + this.CheckpointLayoutState(); + } + + private void RestoreLayoutSnapshotIfAvailable() + { + var snapshot = this.ChatPageSessionService.GetLayoutSnapshot(); + if (snapshot is null) + return; + + this.workspaceOverlayVisible = snapshot.WorkspaceOverlayVisible; + this.workspaceSearchVisible = snapshot.WorkspaceSearchVisible; + this.currentWorkspaceName = snapshot.CurrentWorkspaceName; + this.splitterPosition = snapshot.SplitterPosition; + } + + private void CheckpointLayoutState() + { + this.ChatPageSessionService.StoreLayoutSnapshot(new ChatPageLayoutSnapshot( + this.workspaceOverlayVisible, + this.workspaceSearchVisible, + this.currentWorkspaceName, + this.splitterPosition)); } #region Overrides of MSGComponentBase @@ -150,4 +182,4 @@ public partial class Chat : MSGComponentBase return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index b3d58859..a1aab261 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -148,6 +148,8 @@ internal sealed class Program builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // ReSharper disable AccessToDisposedClosure builder.Services.AddHostedService(_ => rust); @@ -158,7 +160,7 @@ internal sealed class Program .AddHubOptions(options => { options.MaximumReceiveMessageSize = null; - options.ClientTimeoutInterval = TimeSpan.FromDays(14); + options.ClientTimeoutInterval = TimeSpan.FromSeconds(120); options.HandshakeTimeout = TimeSpan.FromSeconds(30); }); diff --git a/app/MindWork AI Studio/Routes.razor b/app/MindWork AI Studio/Routes.razor index 3988f98d..de2ed25f 100644 --- a/app/MindWork AI Studio/Routes.razor +++ b/app/MindWork AI Studio/Routes.razor @@ -3,11 +3,11 @@ - + - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Routes.razor.cs b/app/MindWork AI Studio/Routes.razor.cs index fa1aa89f..23dc9c0f 100644 --- a/app/MindWork AI Studio/Routes.razor.cs +++ b/app/MindWork AI Studio/Routes.razor.cs @@ -1,7 +1,30 @@ +using Microsoft.AspNetCore.Components; + namespace AIStudio; -public sealed partial class Routes +public sealed partial class Routes : IDisposable { + [Inject] + private Tools.Services.ReconnectRecoveryService ReconnectRecoveryService { get; init; } = null!; + + private int recoveryGeneration; + + protected override void OnInitialized() + { + this.recoveryGeneration = this.ReconnectRecoveryService.Generation; + this.ReconnectRecoveryService.Changed += this.OnReconnectRecoveryChanged; + base.OnInitialized(); + } + + private void OnReconnectRecoveryChanged() + { + _ = this.InvokeAsync(() => + { + this.recoveryGeneration = this.ReconnectRecoveryService.Generation; + this.StateHasChanged(); + }); + } + public const string HOME = "/"; public const string CHAT = "/chat"; public const string ABOUT = "/about"; @@ -33,4 +56,9 @@ public sealed partial class Routes public const string ASSISTANT_DYNAMIC = "/assistant/dynamic"; public const string ASSISTANT_META_ASSISTANT = "/assistant/builder"; // ReSharper restore InconsistentNaming + + public void Dispose() + { + this.ReconnectRecoveryService.Changed -= this.OnReconnectRecoveryChanged; + } } diff --git a/app/MindWork AI Studio/Tools/Services/ChatPageSessionService.cs b/app/MindWork AI Studio/Tools/Services/ChatPageSessionService.cs new file mode 100644 index 00000000..ed0ca513 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/ChatPageSessionService.cs @@ -0,0 +1,82 @@ +using AIStudio.Chat; +using AIStudio.Components; +using AIStudio.Settings.DataModel; + +namespace AIStudio.Tools.Services; + +public sealed record ChatPageLayoutSnapshot( + bool WorkspaceOverlayVisible, + bool WorkspaceSearchVisible, + string CurrentWorkspaceName, + double SplitterPosition); + +public sealed record ChatPageComponentSnapshot( + ChatThread? ChatThread, + string ProviderId, + string CurrentProfileId, + string CurrentChatTemplateId, + string UserInput, + bool HasUserDraft, + IReadOnlyCollection FileAttachments, + bool HasUnsavedChanges, + bool AutoSaveEnabled, + DataSourceOptions EarlyDataSourceOptions, + DataSourceOptions LastAppliedStandardDataSourceOptions, + string CurrentWorkspaceName, + Guid CurrentWorkspaceId, + Guid CurrentChatThreadId); + +/// +/// Stores chat page state so the active chat page can be rebuilt after reconnect recovery. +/// +public sealed class ChatPageSessionService +{ + private readonly Lock syncRoot = new(); + private ChatPageLayoutSnapshot? layoutSnapshot; + private ChatPageComponentSnapshot? componentSnapshot; + + public void StoreLayoutSnapshot(ChatPageLayoutSnapshot snapshot) + { + lock (this.syncRoot) + this.layoutSnapshot = snapshot; + } + + public ChatPageLayoutSnapshot? GetLayoutSnapshot() + { + lock (this.syncRoot) + return this.layoutSnapshot; + } + + public void StoreComponentSnapshot(ChatPageComponentSnapshot snapshot) + { + var normalizedAttachments = snapshot.FileAttachments + .Select(attachment => attachment.Normalize()) + .ToArray(); + + lock (this.syncRoot) + { + this.componentSnapshot = snapshot with + { + FileAttachments = normalizedAttachments, + EarlyDataSourceOptions = snapshot.EarlyDataSourceOptions.CreateCopy(), + LastAppliedStandardDataSourceOptions = snapshot.LastAppliedStandardDataSourceOptions.CreateCopy(), + }; + } + } + + public ChatPageComponentSnapshot? GetComponentSnapshot() + { + lock (this.syncRoot) + { + if (this.componentSnapshot is null) + return null; + + return this.componentSnapshot with + { + FileAttachments = this.componentSnapshot.FileAttachments.ToArray(), + EarlyDataSourceOptions = this.componentSnapshot.EarlyDataSourceOptions.CreateCopy(), + LastAppliedStandardDataSourceOptions = this.componentSnapshot.LastAppliedStandardDataSourceOptions.CreateCopy(), + }; + } + } +} diff --git a/app/MindWork AI Studio/Tools/Services/ReconnectRecoveryService.cs b/app/MindWork AI Studio/Tools/Services/ReconnectRecoveryService.cs new file mode 100644 index 00000000..66f724f8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/ReconnectRecoveryService.cs @@ -0,0 +1,28 @@ +namespace AIStudio.Tools.Services; + +/// +/// Coordinates UI recovery after the Blazor circuit reconnects. +/// +public sealed class ReconnectRecoveryService +{ + private int generation; + + /// + /// Raised when the reconnect recovery generation changes. + /// + public event Action? Changed; + + /// + /// Gets the current reconnect recovery generation. + /// + public int Generation => Volatile.Read(ref this.generation); + + /// + /// Marks reconnect recovery as completed and requests a route subtree remount. + /// + public void NotifyRecovered() + { + Interlocked.Increment(ref this.generation); + this.Changed?.Invoke(); + } +} diff --git a/app/MindWork AI Studio/wwwroot/boot.js b/app/MindWork AI Studio/wwwroot/boot.js index d18dd7e9..ec0dcf75 100644 --- a/app/MindWork AI Studio/wwwroot/boot.js +++ b/app/MindWork AI Studio/wwwroot/boot.js @@ -1,64 +1,172 @@ (() => { - const maximumRetryCount = 3; - const retryIntervalMilliseconds = 500; + const MAXIMUM_RETRY_COUNT = 12; const reconnectModal = document.getElementById('reconnect-modal'); + const reconnectRecoveryHandlers = new Map(); + const reconnectRecoveredEventName = 'aistudio:reconnect-recovered'; + + let currentReconnectionProcess = null; + let isConnectionDown = false; + + const retryDelaysMilliseconds = [ + 0, + 1_000, + 2_000, + 5_000, + 10_000, + 15_000, + 30_000, + ]; + + const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + + const getRetryDelayMilliseconds = attempt => retryDelaysMilliseconds[Math.min(attempt, retryDelaysMilliseconds.length - 1)]; + + const setReconnectModalText = text => { + if (!reconnectModal) + return; + + reconnectModal.textContent = text; + }; + + const showReconnectModal = () => { + if (!reconnectModal) + return; + + reconnectModal.style.display = 'flex'; + }; + + const hideReconnectModal = () => { + if (!reconnectModal) + return; + + reconnectModal.style.display = 'none'; + }; + + const notifyReconnectRecovered = () => { + window.dispatchEvent(new CustomEvent(reconnectRecoveredEventName)); + }; const startReconnectionProcess = () => { - reconnectModal.style.display = 'block'; + showReconnectModal(); let isCanceled = false; + let forceAttempt = false; + let isRunning = false; - (async () => { - for (let i = 0; i < maximumRetryCount; i++) { - reconnectModal.innerText = `Attempting to reconnect: ${i + 1} of ${maximumRetryCount}`; - - await new Promise(resolve => setTimeout(resolve, retryIntervalMilliseconds)); - - if (isCanceled) { - return; - } - - try { - const result = await Blazor.reconnect(); - if (!result) { - // The server was reached, but the connection was rejected; reload the page. - location.reload(); - return; - } - - // Successfully reconnected to the server. - return; - } catch { - // Didn't reach the server; try again. - } + const waitForNextAttemptAsync = async milliseconds => { + const startedAt = Date.now(); + while (!isCanceled && !forceAttempt && Date.now() - startedAt < milliseconds) { + await delay(250); } - // Retried too many times; reload the page. - location.reload(); - })(); + forceAttempt = false; + }; + + const runAsync = async () => { + if (isRunning) + return; + + isRunning = true; + + try { + for (let attempt = 0; attempt < MAXIMUM_RETRY_COUNT && !isCanceled; attempt++) { + setReconnectModalText(`Reconnecting to AI Studio (${attempt + 1}/${MAXIMUM_RETRY_COUNT})...`); + + const delayMilliseconds = attempt == 0 ? 0 : getRetryDelayMilliseconds(attempt - 1); + if (delayMilliseconds > 0) + await waitForNextAttemptAsync(delayMilliseconds); + + if (isCanceled) + return; + + try { + const result = await Blazor.reconnect(); + if (result === false) { + location.reload(); + return; + } + + if (result === true) + return; + } catch { + // Ignore transient transport failures and keep retrying. + } + } + + if (!isCanceled) + location.reload(); + } finally { + isRunning = false; + } + }; + + void runAsync(); return { cancel: () => { isCanceled = true; - reconnectModal.style.display = 'none'; + hideReconnectModal(); + }, + triggerImmediateAttempt: () => { + forceAttempt = true; + void runAsync(); }, }; }; - let currentReconnectionProcess = null; + const triggerReconnectAfterWake = () => { + if (!isConnectionDown) + return; + + currentReconnectionProcess?.triggerImmediateAttempt(); + }; + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') + triggerReconnectAfterWake(); + }); + + window.addEventListener('pageshow', () => { + triggerReconnectAfterWake(); + }); + + window.registerReconnectRecovery = function (id, dotNetReference) { + window.unregisterReconnectRecovery(id); + + const handler = function () { + dotNetReference.invokeMethodAsync('HandleReconnectRecoveryAsync').catch(() => {}); + }; + + window.addEventListener(reconnectRecoveredEventName, handler); + reconnectRecoveryHandlers.set(id, handler); + }; + + window.unregisterReconnectRecovery = function (id) { + const handler = reconnectRecoveryHandlers.get(id); + if (!handler) + return; + + window.removeEventListener(reconnectRecoveredEventName, handler); + reconnectRecoveryHandlers.delete(id); + }; Blazor.start({ circuit: { reconnectionHandler: { - onConnectionDown: () => currentReconnectionProcess ??= startReconnectionProcess(), + onConnectionDown: () => { + isConnectionDown = true; + currentReconnectionProcess ??= startReconnectionProcess(); + }, onConnectionUp: () => { + isConnectionDown = false; currentReconnectionProcess?.cancel(); currentReconnectionProcess = null; - } + notifyReconnectRecovered(); + }, }, configureSignalR: function (builder) { - builder.withServerTimeout(1_200_000); + builder.withServerTimeout(120_000); builder.withKeepAliveInterval(30_000); }, }