mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-23 18:32:11 +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;">
|
<body style="overflow: hidden;">
|
||||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)"/>
|
<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="_framework/blazor.web.js" autostart="false"></script>
|
||||||
<script src="boot.js"></script>
|
<script src="boot.js"></script>
|
||||||
<script src="system/MudBlazor/MudBlazor.min.js"></script>
|
<script src="system/MudBlazor/MudBlazor.min.js"></script>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using AIStudio.Provider;
|
|||||||
using AIStudio.Settings;
|
using AIStudio.Settings;
|
||||||
using AIStudio.Settings.DataModel;
|
using AIStudio.Settings.DataModel;
|
||||||
using AIStudio.Tools.AIJobs;
|
using AIStudio.Tools.AIJobs;
|
||||||
|
using AIStudio.Tools.Services;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
@ -54,6 +55,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
[Inject]
|
[Inject]
|
||||||
private AIJobService AIJobService { get; init; } = null!;
|
private AIJobService AIJobService { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ChatPageSessionService ChatPageSessionService { get; init; } = null!;
|
||||||
|
|
||||||
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
|
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
|
||||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||||
|
|
||||||
@ -97,7 +101,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
private string UserInput
|
private string UserInput
|
||||||
{
|
{
|
||||||
get => this.ComposerState.UserInput;
|
get => this.ComposerState.UserInput;
|
||||||
set => this.ComposerState.SetUserInput(value);
|
set
|
||||||
|
{
|
||||||
|
this.ComposerState.SetUserInput(value);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Overrides of ComponentBase
|
#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.");
|
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
|
// When for whatever reason we have a chat thread, we have to
|
||||||
// ensure that the corresponding workspace id is set and the
|
// 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.SelectProviderWhenLoadingChat();
|
||||||
await this.SyncForegroundChatAsync();
|
await this.SyncForegroundChatAsync();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
@ -300,6 +312,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.previousInputForbidden = inputForbidden;
|
this.previousInputForbidden = inputForbidden;
|
||||||
await base.OnAfterRenderAsync(firstRender);
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
@ -315,6 +328,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await this.ApplyLoadedChatParameterAsync();
|
await this.ApplyLoadedChatParameterAsync();
|
||||||
await this.SyncForegroundChatAsync();
|
await this.SyncForegroundChatAsync();
|
||||||
await base.OnParametersSetAsync();
|
await base.OnParametersSetAsync();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -375,6 +389,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.currentChatThreadId = chatThreadId;
|
this.currentChatThreadId = chatThreadId;
|
||||||
this.currentWorkspaceId = workspaceId;
|
this.currentWorkspaceId = workspaceId;
|
||||||
this.PublishWorkspaceNameIfChanged(loadedWorkspaceName);
|
this.PublishWorkspaceNameIfChanged(loadedWorkspaceName);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearWorkspaceHeaderState()
|
private void ClearWorkspaceHeaderState()
|
||||||
@ -382,6 +397,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.currentChatThreadId = Guid.Empty;
|
this.currentChatThreadId = Guid.Empty;
|
||||||
this.currentWorkspaceId = Guid.Empty;
|
this.currentWorkspaceId = Guid.Empty;
|
||||||
this.PublishWorkspaceNameIfChanged(string.Empty);
|
this.PublishWorkspaceNameIfChanged(string.Empty);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PublishWorkspaceNameIfChanged(string workspaceName)
|
private void PublishWorkspaceNameIfChanged(string workspaceName)
|
||||||
@ -394,6 +410,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.currentWorkspaceName = workspaceName;
|
this.currentWorkspaceName = workspaceName;
|
||||||
this.WorkspaceName(this.currentWorkspaceName);
|
this.WorkspaceName(this.currentWorkspaceName);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshRenamedWorkspaceHeaderAsync(Guid workspaceId)
|
private async Task RefreshRenamedWorkspaceHeaderAsync(Guid workspaceId)
|
||||||
@ -417,6 +434,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.currentChatThreadId = chatThreadId;
|
this.currentChatThreadId = chatThreadId;
|
||||||
this.currentWorkspaceId = workspaceId;
|
this.currentWorkspaceId = workspaceId;
|
||||||
this.PublishWorkspaceNameIfChanged(loadedWorkspaceName);
|
this.PublishWorkspaceNameIfChanged(loadedWorkspaceName);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncForegroundChatAsync()
|
private async Task SyncForegroundChatAsync()
|
||||||
@ -467,6 +485,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.ChatThread.DataSourceOptions = chatDefaultOptions;
|
this.ChatThread.DataSourceOptions = chatDefaultOptions;
|
||||||
|
|
||||||
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(chatDefaultOptions);
|
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(chatDefaultOptions);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange()
|
private async Task ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange()
|
||||||
@ -479,6 +498,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
this.earlyDataSourceOptions = updatedStandardOptions;
|
this.earlyDataSourceOptions = updatedStandardOptions;
|
||||||
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions);
|
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -488,6 +508,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await this.SetCurrentDataSourceOptions(updatedStandardOptions);
|
await this.SetCurrentDataSourceOptions(updatedStandardOptions);
|
||||||
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions, this.ChatThread.AISelectedDataSources);
|
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions, this.ChatThread.AISelectedDataSources);
|
||||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool DataSourceOptionsAreEqual(DataSourceOptions left, DataSourceOptions right)
|
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);
|
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
|
||||||
if(this.ChatThread is null)
|
if(this.ChatThread is null)
|
||||||
|
{
|
||||||
|
this.CheckpointChatPageState();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.ChatThread = this.ChatThread with
|
this.ChatThread = this.ChatThread with
|
||||||
{
|
{
|
||||||
@ -529,6 +553,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
|
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
|
||||||
@ -541,7 +566,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
|
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
|
||||||
|
|
||||||
if(this.ChatThread is null)
|
if(this.ChatThread is null)
|
||||||
|
{
|
||||||
|
this.CheckpointChatPageState();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.StartNewChat(true);
|
await this.StartNewChat(true);
|
||||||
}
|
}
|
||||||
@ -582,6 +610,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
|
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
|
||||||
|
|
||||||
await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange();
|
await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources()
|
private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources()
|
||||||
@ -614,6 +643,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
this.earlyDataSourceOptions = updatedOptions;
|
this.earlyDataSourceOptions = updatedOptions;
|
||||||
|
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsInputForbidden()
|
private bool IsInputForbidden()
|
||||||
@ -637,6 +668,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
this.ComposerState.MarkUserDraft();
|
this.ComposerState.MarkUserDraft();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
var key = keyEvent.Code.ToLowerInvariant();
|
var key = keyEvent.Code.ToLowerInvariant();
|
||||||
|
|
||||||
// Was the enter key (either enter or numpad enter) pressed?
|
// 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.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
|
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
|
||||||
@ -679,6 +712,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.ComposerState.MarkUserDraft();
|
this.ComposerState.MarkUserDraft();
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendMessage(bool reuseLastUserPrompt = false)
|
private async Task SendMessage(bool reuseLastUserPrompt = false)
|
||||||
@ -813,6 +847,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.SyncForegroundChatAsync();
|
await this.SyncForegroundChatAsync();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -841,6 +876,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
|
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
|
||||||
|
|
||||||
this.hasUnsavedChanges = false;
|
this.hasUnsavedChanges = false;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
|
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
|
||||||
@ -950,6 +986,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await this.SyncForegroundChatAsync();
|
await this.SyncForegroundChatAsync();
|
||||||
this.MarkCurrentChatAsLoadedParameter();
|
this.MarkCurrentChatAsLoadedParameter();
|
||||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MoveChatToWorkspace()
|
private async Task MoveChatToWorkspace()
|
||||||
@ -994,6 +1031,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await this.SaveThread();
|
await this.SaveThread();
|
||||||
|
|
||||||
await this.SyncWorkspaceHeaderWithChatThreadAsync();
|
await this.SyncWorkspaceHeaderWithChatThreadAsync();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadedChatChanged(bool notifyParent = true)
|
private async Task LoadedChatChanged(bool notifyParent = true)
|
||||||
@ -1029,6 +1067,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.scrollRenderCountdown = 2;
|
this.scrollRenderCountdown = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1043,11 +1082,15 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
await this.SyncForegroundChatAsync();
|
await this.SyncForegroundChatAsync();
|
||||||
this.ApplyStandardDataSourceOptions();
|
this.ApplyStandardDataSourceOptions();
|
||||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectProviderWhenLoadingChat()
|
private async Task SelectProviderWhenLoadingChat()
|
||||||
{
|
{
|
||||||
var chatProvider = this.ChatThread?.SelectedProvider;
|
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 chatProfile = this.ChatThread?.SelectedProfile;
|
||||||
var chatChatTemplate = this.ChatThread?.SelectedChatTemplate;
|
var chatChatTemplate = this.ChatThread?.SelectedChatTemplate;
|
||||||
|
|
||||||
@ -1062,6 +1105,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
// Try to select the chat template:
|
// Try to select the chat template:
|
||||||
if (!string.IsNullOrWhiteSpace(chatChatTemplate))
|
if (!string.IsNullOrWhiteSpace(chatChatTemplate))
|
||||||
this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatChatTemplate);
|
this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatChatTemplate);
|
||||||
|
|
||||||
|
this.CheckpointChatPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleWorkspaceOverlay()
|
private async Task ToggleWorkspaceOverlay()
|
||||||
@ -1077,6 +1122,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.ChatThread.Remove(block);
|
this.ChatThread.Remove(block);
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
await this.SaveThread();
|
await this.SaveThread();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1090,6 +1136,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.ChatThread.Remove(aiBlock, removeForRegenerate: true);
|
this.ChatThread.Remove(aiBlock, removeForRegenerate: true);
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
|
|
||||||
await this.SendMessage(reuseLastUserPrompt: true);
|
await this.SendMessage(reuseLastUserPrompt: true);
|
||||||
@ -1112,6 +1159,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.ChatThread.Remove(block);
|
this.ChatThread.Remove(block);
|
||||||
this.ChatThread.Remove(lastBlockContent);
|
this.ChatThread.Remove(lastBlockContent);
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@ -1128,6 +1176,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.RestoreComposerFromTextBlock(textBlock);
|
this.RestoreComposerFromTextBlock(textBlock);
|
||||||
this.ChatThread.Remove(block);
|
this.ChatThread.Remove(block);
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@ -1136,6 +1185,50 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
private void RestoreComposerFromTextBlock(ContentText textBlock)
|
private void RestoreComposerFromTextBlock(ContentText textBlock)
|
||||||
{
|
{
|
||||||
this.ComposerState.RestoreFromTextBlock(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
|
#region Overrides of MSGComponentBase
|
||||||
@ -1155,6 +1248,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
if(this.autoSaveEnabled)
|
if(this.autoSaveEnabled)
|
||||||
await this.SaveThread();
|
await this.SaveThread();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Event.WORKSPACE_RENAMED:
|
case Event.WORKSPACE_RENAMED:
|
||||||
@ -1165,6 +1259,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
case Event.CONFIGURATION_CHANGED:
|
case Event.CONFIGURATION_CHANGED:
|
||||||
case Event.PLUGINS_RELOADED:
|
case Event.PLUGINS_RELOADED:
|
||||||
await this.RefreshChatSelectionsAfterConfigurationChange();
|
await this.RefreshChatSelectionsAfterConfigurationChange();
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -1180,6 +1275,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
this.previousInputForbidden = true;
|
this.previousInputForbidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -1216,8 +1312,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
|
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
|
||||||
|
this.CheckpointChatPageState();
|
||||||
this.Dispose();
|
this.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,4 +62,11 @@ public sealed class ChatComposerState
|
|||||||
this.ReplaceFileAttachments(textBlock.FileAttachments);
|
this.ReplaceFileAttachments(textBlock.FileAttachments);
|
||||||
this.HasUserDraft = true;
|
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]
|
[Inject]
|
||||||
private NavigationManager NavigationManager { get; init; } = null!;
|
private NavigationManager NavigationManager { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<MainLayout> Logger { get; init; } = null!;
|
private ILogger<MainLayout> Logger { get; init; } = null!;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private MudTheme ColorTheme { get; init; } = null!;
|
private MudTheme ColorTheme { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ReconnectRecoveryService ReconnectRecoveryService { get; init; } = null!;
|
||||||
|
|
||||||
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
|
||||||
|
|
||||||
@ -64,6 +70,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
private bool startupCompleted;
|
private bool startupCompleted;
|
||||||
private bool settingsWriteProtectionWarningShown;
|
private bool settingsWriteProtectionWarningShown;
|
||||||
private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1);
|
private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1);
|
||||||
|
private readonly string reconnectRecoveryHandlerId = $"reconnect-recovery-{Guid.NewGuid()}";
|
||||||
|
private DotNetObjectReference<MainLayout>? dotNetReference;
|
||||||
|
|
||||||
private IReadOnlyCollection<NavBarItem> navItems = [];
|
private IReadOnlyCollection<NavBarItem> navItems = [];
|
||||||
|
|
||||||
@ -130,6 +138,17 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
await base.OnInitializedAsync();
|
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
|
#endregion
|
||||||
|
|
||||||
private void ShowSettingsWriteProtectionWarning()
|
private void ShowSettingsWriteProtectionWarning()
|
||||||
@ -519,14 +538,32 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
|
|
||||||
await this.SettingsManager.StoreSettings();
|
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
|
#region Implementation of IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
this.MessageBus.Unregister(this);
|
this.MessageBus.Unregister(this);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = this.JsRuntime.InvokeVoidAsync("unregisterReconnectRecovery", this.reconnectRecoveryHandlerId).AsTask();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dotNetReference?.Dispose();
|
||||||
this.mandatoryInfoDialogSemaphore.Dispose();
|
this.mandatoryInfoDialogSemaphore.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using AIStudio.Chat;
|
|||||||
using AIStudio.Components;
|
using AIStudio.Components;
|
||||||
using AIStudio.Dialogs.Settings;
|
using AIStudio.Dialogs.Settings;
|
||||||
using AIStudio.Settings.DataModel;
|
using AIStudio.Settings.DataModel;
|
||||||
|
using AIStudio.Tools.Services;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||||
@ -19,6 +20,9 @@ public partial class Chat : MSGComponentBase
|
|||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private IDialogService DialogService { get; init; } = null!;
|
private IDialogService DialogService { get; init; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ChatPageSessionService ChatPageSessionService { get; init; } = null!;
|
||||||
|
|
||||||
private ChatThread? chatThread;
|
private ChatThread? chatThread;
|
||||||
private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE;
|
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.ApplyFilters([], [ Event.WORKSPACE_TOGGLE_OVERLAY ]);
|
||||||
|
|
||||||
this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition;
|
this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition;
|
||||||
|
this.RestoreLayoutSnapshotIfAvailable();
|
||||||
this.splitterSaveTimer.AutoReset = false;
|
this.splitterSaveTimer.AutoReset = false;
|
||||||
this.splitterSaveTimer.Elapsed += async (_, _) =>
|
this.splitterSaveTimer.Elapsed += async (_, _) =>
|
||||||
{
|
{
|
||||||
@ -46,6 +51,7 @@ public partial class Chat : MSGComponentBase
|
|||||||
};
|
};
|
||||||
|
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
|
this.CheckpointLayoutState();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -68,6 +74,7 @@ public partial class Chat : MSGComponentBase
|
|||||||
{
|
{
|
||||||
this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible = !this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible;
|
this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible = !this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible;
|
||||||
await this.SettingsManager.StoreSettings();
|
await this.SettingsManager.StoreSettings();
|
||||||
|
this.CheckpointLayoutState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SplitterChanged(double position)
|
private void SplitterChanged(double position)
|
||||||
@ -75,11 +82,13 @@ public partial class Chat : MSGComponentBase
|
|||||||
this.splitterPosition = position;
|
this.splitterPosition = position;
|
||||||
this.splitterSaveTimer.Stop();
|
this.splitterSaveTimer.Stop();
|
||||||
this.splitterSaveTimer.Start();
|
this.splitterSaveTimer.Start();
|
||||||
|
this.CheckpointLayoutState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleWorkspacesOverlay()
|
private void ToggleWorkspacesOverlay()
|
||||||
{
|
{
|
||||||
this.workspaceOverlayVisible = !this.workspaceOverlayVisible;
|
this.workspaceOverlayVisible = !this.workspaceOverlayVisible;
|
||||||
|
this.CheckpointLayoutState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,6 +97,7 @@ public partial class Chat : MSGComponentBase
|
|||||||
private void UpdateWorkspaceName(string workspaceName)
|
private void UpdateWorkspaceName(string workspaceName)
|
||||||
{
|
{
|
||||||
this.currentWorkspaceName = workspaceName;
|
this.currentWorkspaceName = workspaceName;
|
||||||
|
this.CheckpointLayoutState();
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,6 +128,28 @@ public partial class Chat : MSGComponentBase
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
await this.workspaces.ToggleSearchAsync();
|
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
|
#region Overrides of MSGComponentBase
|
||||||
@ -150,4 +182,4 @@ public partial class Chat : MSGComponentBase
|
|||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,8 @@ internal sealed class Program
|
|||||||
builder.Services.AddSingleton<DatabaseClientProvider>();
|
builder.Services.AddSingleton<DatabaseClientProvider>();
|
||||||
builder.Services.AddHostedService<GlobalShortcutService>();
|
builder.Services.AddHostedService<GlobalShortcutService>();
|
||||||
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
|
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
|
||||||
|
builder.Services.AddSingleton<ReconnectRecoveryService>();
|
||||||
|
builder.Services.AddSingleton<ChatPageSessionService>();
|
||||||
|
|
||||||
// ReSharper disable AccessToDisposedClosure
|
// ReSharper disable AccessToDisposedClosure
|
||||||
builder.Services.AddHostedService<RustService>(_ => rust);
|
builder.Services.AddHostedService<RustService>(_ => rust);
|
||||||
@ -158,7 +160,7 @@ internal sealed class Program
|
|||||||
.AddHubOptions(options =>
|
.AddHubOptions(options =>
|
||||||
{
|
{
|
||||||
options.MaximumReceiveMessageSize = null;
|
options.MaximumReceiveMessageSize = null;
|
||||||
options.ClientTimeoutInterval = TimeSpan.FromDays(14);
|
options.ClientTimeoutInterval = TimeSpan.FromSeconds(120);
|
||||||
options.HandshakeTimeout = TimeSpan.FromSeconds(30);
|
options.HandshakeTimeout = TimeSpan.FromSeconds(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
<Router AppAssembly="typeof(Program).Assembly">
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
<Found Context="routeData">
|
<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"/>
|
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
||||||
<MudDialogProvider />
|
<MudDialogProvider />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
<MudSnackbarProvider />
|
<MudSnackbarProvider />
|
||||||
|
|||||||
@ -1,7 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace AIStudio;
|
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 HOME = "/";
|
||||||
public const string CHAT = "/chat";
|
public const string CHAT = "/chat";
|
||||||
public const string ABOUT = "/about";
|
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_DYNAMIC = "/assistant/dynamic";
|
||||||
public const string ASSISTANT_META_ASSISTANT = "/assistant/builder";
|
public const string ASSISTANT_META_ASSISTANT = "/assistant/builder";
|
||||||
// ReSharper restore InconsistentNaming
|
// 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 MAXIMUM_RETRY_COUNT = 12;
|
||||||
const retryIntervalMilliseconds = 500;
|
|
||||||
const reconnectModal = document.getElementById('reconnect-modal');
|
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 = () => {
|
const startReconnectionProcess = () => {
|
||||||
reconnectModal.style.display = 'block';
|
showReconnectModal();
|
||||||
|
|
||||||
let isCanceled = false;
|
let isCanceled = false;
|
||||||
|
let forceAttempt = false;
|
||||||
|
let isRunning = false;
|
||||||
|
|
||||||
(async () => {
|
const waitForNextAttemptAsync = async milliseconds => {
|
||||||
for (let i = 0; i < maximumRetryCount; i++) {
|
const startedAt = Date.now();
|
||||||
reconnectModal.innerText = `Attempting to reconnect: ${i + 1} of ${maximumRetryCount}`;
|
while (!isCanceled && !forceAttempt && Date.now() - startedAt < milliseconds) {
|
||||||
|
await delay(250);
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retried too many times; reload the page.
|
forceAttempt = false;
|
||||||
location.reload();
|
};
|
||||||
})();
|
|
||||||
|
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 {
|
return {
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
isCanceled = true;
|
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({
|
Blazor.start({
|
||||||
circuit: {
|
circuit: {
|
||||||
reconnectionHandler: {
|
reconnectionHandler: {
|
||||||
onConnectionDown: () => currentReconnectionProcess ??= startReconnectionProcess(),
|
onConnectionDown: () => {
|
||||||
|
isConnectionDown = true;
|
||||||
|
currentReconnectionProcess ??= startReconnectionProcess();
|
||||||
|
},
|
||||||
onConnectionUp: () => {
|
onConnectionUp: () => {
|
||||||
|
isConnectionDown = false;
|
||||||
currentReconnectionProcess?.cancel();
|
currentReconnectionProcess?.cancel();
|
||||||
currentReconnectionProcess = null;
|
currentReconnectionProcess = null;
|
||||||
}
|
notifyReconnectRecovered();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
configureSignalR: function (builder) {
|
configureSignalR: function (builder) {
|
||||||
builder.withServerTimeout(1_200_000);
|
builder.withServerTimeout(120_000);
|
||||||
builder.withKeepAliveInterval(30_000);
|
builder.withKeepAliveInterval(30_000);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user