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 @@
+
+ Reconnecting to AI Studio...
+
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);
},
}