Introduce session persistence and reconnect recovery mechanisms.

This commit is contained in:
hart_s3 2026-07-09 14:00:00 +02:00
parent 331b8f3764
commit 574fa4a385
11 changed files with 467 additions and 43 deletions

View File

@ -21,6 +21,9 @@
<body style="overflow: hidden;">
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)"/>
<div id="reconnect-modal" style="display: none; position: fixed; inset: 0; z-index: 20000; align-items: center; justify-content: center; padding: 2rem; background: rgba(15, 23, 42, 0.82); color: white; font-size: 1.1rem; text-align: center;">
Reconnecting to AI Studio...
</div>
<script src="_framework/blazor.web.js" autostart="false"></script>
<script src="boot.js"></script>
<script src="system/MudBlazor/MudBlazor.min.js"></script>

View File

@ -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<string, object?> 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<DataSourceAgentSelected> 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<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
this.hasUnsavedChanges = true;
this.CheckpointChatPageState();
}
private void ComposerAttachmentsChanged(HashSet<FileAttachment> 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
}
}

View File

@ -62,4 +62,11 @@ public sealed class ChatComposerState
this.ReplaceFileAttachments(textBlock.FileAttachments);
this.HasUserDraft = true;
}
}
public void Restore(string? userInput, IEnumerable<FileAttachment> fileAttachments, bool hasUserDraft)
{
this.UserInput = userInput ?? string.Empty;
this.ReplaceFileAttachments(fileAttachments);
this.HasUserDraft = hasUserDraft;
}
}

View File

@ -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<MainLayout> 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<MainLayout>? dotNetReference;
private IReadOnlyCollection<NavBarItem> 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
}
}

View File

@ -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;
}
}
}

View File

@ -148,6 +148,8 @@ internal sealed class Program
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>();
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
builder.Services.AddSingleton<ReconnectRecoveryService>();
builder.Services.AddSingleton<ChatPageSessionService>();
// ReSharper disable AccessToDisposedClosure
builder.Services.AddHostedService<RustService>(_ => 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);
});

View File

@ -3,11 +3,11 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<RouteView @key="@this.recoveryGeneration" RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
<MudDialogProvider />
<MudPopoverProvider />
<MudSnackbarProvider />
<MudSnackbarProvider />

View File

@ -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;
}
}

View File

@ -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<FileAttachment> FileAttachments,
bool HasUnsavedChanges,
bool AutoSaveEnabled,
DataSourceOptions EarlyDataSourceOptions,
DataSourceOptions LastAppliedStandardDataSourceOptions,
string CurrentWorkspaceName,
Guid CurrentWorkspaceId,
Guid CurrentChatThreadId);
/// <summary>
/// Stores chat page state so the active chat page can be rebuilt after reconnect recovery.
/// </summary>
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(),
};
}
}
}

View File

@ -0,0 +1,28 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// Coordinates UI recovery after the Blazor circuit reconnects.
/// </summary>
public sealed class ReconnectRecoveryService
{
private int generation;
/// <summary>
/// Raised when the reconnect recovery generation changes.
/// </summary>
public event Action? Changed;
/// <summary>
/// Gets the current reconnect recovery generation.
/// </summary>
public int Generation => Volatile.Read(ref this.generation);
/// <summary>
/// Marks reconnect recovery as completed and requests a route subtree remount.
/// </summary>
public void NotifyRecovered()
{
Interlocked.Increment(ref this.generation);
this.Changed?.Invoke();
}
}

View File

@ -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);
},
}