mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:32:10 +00:00
Introduce session persistence and reconnect recovery mechanisms.
This commit is contained in:
parent
331b8f3764
commit
574fa4a385
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user