mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 00:18:00 +00:00
Added backups of all data, restorable on another computer (#1008)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
ab45580473
commit
936043319c
144 files changed
+10417
-424
No files matched your search
@@ -0,0 +1,225 @@
|
||||
@using AIStudio.Tools.ChatArchive
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudTabs Elevation="0" Rounded="@true" ApplyEffectsToContainer="@true" Outlined="@true" PanelClass="pa-4">
|
||||
<MudTabPanel Text="@T("Back up")" Icon="@Icons.Material.Filled.Archive" Disabled="@(this.isBusy || this.DataRestoreService.IsPreparing)">
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataBackupService.IsRunning)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.BackupProgressPercentage" Indeterminate="@(this.DataBackupService.Progress.Total is 0)" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.BackupProgressText
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.DataBackupService.Cancel">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption">
|
||||
@T("The backup goes on when you leave this page.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<LockableButton Text="@T("Back up data")" Icon="@Icons.Material.Filled.Save" OnClickAsync="@this.StartBackupAsync" Disabled="@(() => this.isBusy)" IsLocked="@(() => !this.AllowBackup)" NoMargin="@true"/>
|
||||
|
||||
@if (this.DataBackupService.LastResult is { } backupResult)
|
||||
{
|
||||
@if (backupResult.Success)
|
||||
{
|
||||
<MudAlert Severity="@(backupResult.IncludesDataSourceIndexes ? Severity.Success : Severity.Warning)" Dense="@true" Class="mt-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
|
||||
</MudText>
|
||||
@if (!backupResult.IncludesDataSourceIndexes)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew.")
|
||||
</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else if (backupResult.Cancelled)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="@true" Class="mt-3">
|
||||
@T("The backup was stopped. No file was left behind.")
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
|
||||
@backupResult.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@T("Restore")" Icon="@Icons.Material.Filled.SettingsBackupRestore" Disabled="@(this.isBusy || this.DataBackupService.IsRunning)">
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataRestoreService.IsRestoreUnfinished)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
|
||||
@T("AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.")
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-3">
|
||||
<LockableButton Text="@T("Choose backup")" Icon="@Icons.Material.Filled.FolderOpen" OnClickAsync="@this.ChooseArchiveAsync" Disabled="@(() => this.isBusy || this.DataRestoreService.IsPreparing)" IsLocked="@(() => !this.AllowRestore)" NoMargin="@true"/>
|
||||
@if (!string.IsNullOrWhiteSpace(this.selectedArchivePath))
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.selectedArchivePath
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@if (this.preview is { } backupPreview)
|
||||
{
|
||||
@if (!backupPreview.Success)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
|
||||
@backupPreview.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Outlined="@true" Class="pa-3 mb-4">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@string.Format(T("Created on {0} with AI Studio {1}"), CommonTools.FormatTimestampToGeneral(backupPreview.Manifest.CreatedAt.ToLocalTime().DateTime, this.currentCultureInfo), backupPreview.Manifest.AppVersion)
|
||||
</MudText>
|
||||
|
||||
@foreach (var workspace in backupPreview.Manifest.Workspaces)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@($"{workspace.Name} ({this.ChatCount(workspace.Chats.Count)})")
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (backupPreview.Manifest.TemporaryChats.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@($"{T("Temporary chats")} ({this.ChatCount(backupPreview.Manifest.TemporaryChats.Count)})")
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudText Typo="Typo.h6">
|
||||
@T("Replace all data")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataRestoreService.IsPreparing)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.RestoreProgressPercentage" Indeterminate="@(this.DataRestoreService.Progress.Total is 0)" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.RestoreProgressText
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.DataRestoreService.Cancel">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.RestorePage" OnClick="@this.StartRestoreAsync" Disabled="@(this.isBusy || !this.CanRestore)">
|
||||
@T("Replace all data")
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
@if (this.DataRestoreService.LastResult is { Success: false } restoreResult)
|
||||
{
|
||||
<MudAlert Severity="@(restoreResult.Cancelled ? Severity.Info : Severity.Error)" Dense="@true" Class="mt-3">
|
||||
@(restoreResult.Cancelled ? T("The restore was stopped. All your data stay as they are.") : restoreResult.Issue)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mt-6">
|
||||
@T("Add chats only")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Adds the chats of the backup to the chats you have. Nothing else changes.")
|
||||
</MudText>
|
||||
|
||||
<MudSelect T="ChatArchiveCollisionBehavior" @bind-Value="@this.collisionBehavior" Label="@T("When a chat already exists")" Variant="Variant.Outlined" Margin="Margin.Dense" Disabled="@this.isBusy" Class="mb-3">
|
||||
<MudSelectItem T="ChatArchiveCollisionBehavior" Value="@ChatArchiveCollisionBehavior.SKIP">
|
||||
@T("Keep the existing chat")
|
||||
</MudSelectItem>
|
||||
<MudSelectItem T="ChatArchiveCollisionBehavior" Value="@ChatArchiveCollisionBehavior.IMPORT_AS_COPY">
|
||||
@T("Add it as an additional chat")
|
||||
</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (this.isImporting)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.ImportProgressPercentage" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chat {0} of {1}"), this.importProgress.ProcessedChats + 1, this.importProgress.TotalChats)
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.CancelImport">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption">
|
||||
@T("Please stay on this page until this is done. Leaving the page stops adding the chats.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" OnClick="@this.StartImportAsync" Disabled="@(this.isBusy || !this.CanImport)">
|
||||
@this.AddChatsText(backupPreview.Manifest.TotalChatCount)
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (this.importResult is { } importOutcome)
|
||||
{
|
||||
@if (importOutcome.Success)
|
||||
{
|
||||
<MudAlert Severity="@(importOutcome.Cancelled ? Severity.Info : Severity.Success)" Dense="@true" Class="mt-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats added: {0}. Workspaces they went into: {1}."), importOutcome.ImportedChats, importOutcome.ImportedWorkspaces)
|
||||
</MudText>
|
||||
@if (importOutcome.Cancelled)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("Adding the chats was stopped. The chats added until then were kept.")
|
||||
</MudText>
|
||||
}
|
||||
@if (importOutcome.SkippedChats > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats which already existed and stayed as they are: {0}."), importOutcome.SkippedChats)
|
||||
</MudText>
|
||||
}
|
||||
@if (importOutcome.FailedChats > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats which could not be added: {0}."), importOutcome.FailedChats)
|
||||
</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
|
||||
@importOutcome.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.ChatArchive;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Backs up all data into one file, and restores such a backup: as a whole, or only its chats.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It appears on the information page and in the settings. A backup and the preparation of a restore
|
||||
/// run in their services, so leaving the page does not stop them; this component only shows them.
|
||||
/// Adding chats runs here, and leaving the page stops it.
|
||||
/// </remarks>
|
||||
public partial class DataBackupPanel : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The shortest time between two progress renders. Without this, a large backup would re-render
|
||||
/// the whole page once per file or chat.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PROGRESS_RENDER_INTERVAL = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
[Inject]
|
||||
private ILogger<DataBackupPanel> Logger { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataBackupService DataBackupService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataRestoreService DataRestoreService { get; init; } = null!;
|
||||
|
||||
private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP;
|
||||
private CancellationTokenSource? importCancellation;
|
||||
private ChatArchiveProgress importProgress;
|
||||
private ChatArchiveImportPreview? preview;
|
||||
private ChatArchiveImportResult? importResult;
|
||||
private DateTimeOffset lastImportRender;
|
||||
private DateTimeOffset lastServiceRender;
|
||||
private string selectedArchivePath = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The culture of the active language plugin, which dates are shown in.
|
||||
/// </summary>
|
||||
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
|
||||
|
||||
/// <summary>
|
||||
/// Blocks any further action, starting with the click that opens a file dialog. Without this, a
|
||||
/// second click during the open dialog would start a second run.
|
||||
/// </summary>
|
||||
private bool isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Whether chats are being added, which shows their progress.
|
||||
/// </summary>
|
||||
private bool isImporting;
|
||||
|
||||
private bool AllowBackup => this.SettingsManager.ConfigurationData.App.AllowUserToBackUpData;
|
||||
|
||||
private bool AllowRestore => this.SettingsManager.ConfigurationData.App.AllowUserToRestoreData;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chosen backup can replace all data now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The permission is asked here again and not only when choosing the backup: a configuration
|
||||
/// plugin may take it away while a chosen backup waits.
|
||||
/// </remarks>
|
||||
private bool CanRestore => this.preview is { Success: true } && this.AllowRestore && !this.DataRestoreService.IsRestoreUnfinished;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chats of the chosen backup can be added now.
|
||||
/// </summary>
|
||||
private bool CanImport => this.preview is { Success: true, Manifest.TotalChatCount: > 0 } && this.AllowRestore && !this.DataRestoreService.IsPreparing;
|
||||
|
||||
private double ImportProgressPercentage => this.importProgress.TotalChats is 0 ? 0d : this.importProgress.ProcessedChats * 100d / this.importProgress.TotalChats;
|
||||
|
||||
private double BackupProgressPercentage => this.DataBackupService.Progress.Total is 0 ? 0d : this.DataBackupService.Progress.Processed * 100d / this.DataBackupService.Progress.Total;
|
||||
|
||||
private double RestoreProgressPercentage => this.DataRestoreService.Progress.Total is 0 ? 0d : this.DataRestoreService.Progress.Processed * 100d / this.DataRestoreService.Progress.Total;
|
||||
|
||||
private string BackupProgressText => this.DataBackupService.Progress switch
|
||||
{
|
||||
{ Phase: DataBackupPhase.COPYING_INDEXES } current => string.Format(T("Copying the indexes of your data sources: {0} of {1}"), current.Processed + 1, current.Total),
|
||||
{ Phase: DataBackupPhase.WRITING, Total: > 0 } current => string.Format(T("Writing file {0} of {1}"), current.Processed, current.Total),
|
||||
_ => T("Preparing the backup"),
|
||||
};
|
||||
|
||||
private string RestoreProgressText => this.DataRestoreService.Progress is { Total: > 0 } current
|
||||
? string.Format(T("Unpacking file {0} of {1}"), current.Processed, current.Total)
|
||||
: T("Reading the backup");
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.DataBackupService.StateChanged += this.OnServiceStateChanged;
|
||||
this.DataRestoreService.StateChanged += this.OnServiceStateChanged;
|
||||
|
||||
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
this.currentCultureInfo = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.DataBackupService.StateChanged -= this.OnServiceStateChanged;
|
||||
this.DataRestoreService.StateChanged -= this.OnServiceStateChanged;
|
||||
this.importCancellation?.Cancel();
|
||||
this.DisposeImportCancellation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// The number of chats, in the singular for one of them.
|
||||
/// </summary>
|
||||
private string ChatCount(int count) => count is 1 ? T("1 chat") : string.Format(T("{0} chats"), count);
|
||||
|
||||
private string AddChatsText(int count) => count is 1 ? T("Add 1 chat") : string.Format(T("Add {0} chats"), count);
|
||||
|
||||
/// <summary>
|
||||
/// Shows what a backup or the preparation of a restore does, which runs in its service, not here.
|
||||
/// </summary>
|
||||
private void OnServiceStateChanged()
|
||||
{
|
||||
// Render at most every PROGRESS_RENDER_INTERVAL, but never skip the end of a run:
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isRunning = this.DataBackupService.IsRunning || this.DataRestoreService.IsPreparing;
|
||||
if (isRunning && now - this.lastServiceRender < PROGRESS_RENDER_INTERVAL)
|
||||
return;
|
||||
|
||||
this.lastServiceRender = now;
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task StartBackupAsync()
|
||||
{
|
||||
if (this.isBusy || this.DataBackupService.IsRunning || !this.AllowBackup)
|
||||
return;
|
||||
|
||||
string archivePath;
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var suggestedFileName = $"ai-studio-backup-{DateTime.Now:yyyy-MM-dd}{DataBackupFormat.FILE_EXTENSION}";
|
||||
var saveResponse = await this.RustService.SaveFile(T("Back up data"), [FileTypes.BACKUP_ARCHIVE], suggestedFileName);
|
||||
if (saveResponse.UserCancelled)
|
||||
return;
|
||||
|
||||
archivePath = saveResponse.SaveFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
//
|
||||
// The service runs the backup and reports every step through StateChanged, so leaving the
|
||||
// page does not stop it:
|
||||
//
|
||||
await this.DataBackupService.CreateBackupAsync(archivePath);
|
||||
}
|
||||
|
||||
private async Task ChooseArchiveAsync()
|
||||
{
|
||||
if (this.isBusy || !this.AllowRestore)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var selectionResponse = await this.RustService.SelectFile(T("Select a backup"), [FileTypes.BACKUP_ARCHIVE]);
|
||||
if (selectionResponse.UserCancelled)
|
||||
return;
|
||||
|
||||
this.selectedArchivePath = selectionResponse.SelectedFilePath;
|
||||
this.importResult = null;
|
||||
this.preview = await ChatArchiveImporter.ReadPreviewAsync(this.selectedArchivePath, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
|
||||
// The lockable button calls this as a plain function, not as an event of this
|
||||
// component, so nothing renders the preview unless we do:
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks whether to replace all data with those of the chosen backup, and prepares the restore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once the restore is prepared, the main layout asks for the restart and blocks everything else
|
||||
/// until then: whatever changes meanwhile would be lost.
|
||||
/// </remarks>
|
||||
private async Task StartRestoreAsync()
|
||||
{
|
||||
if (this.isBusy || !this.CanRestore)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, T("Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?") },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Replace All Data"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
await this.DataRestoreService.PrepareRestoreAsync(this.selectedArchivePath);
|
||||
}
|
||||
|
||||
private async Task StartImportAsync()
|
||||
{
|
||||
if (this.isBusy || !this.CanImport)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
this.importResult = null;
|
||||
this.isImporting = true;
|
||||
this.importProgress = new(0, this.preview!.Manifest.TotalChatCount);
|
||||
this.importCancellation = new();
|
||||
|
||||
var archivePath = this.selectedArchivePath;
|
||||
var token = this.importCancellation.Token;
|
||||
var reporter = this.CreateImportProgressReporter();
|
||||
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, this.collisionBehavior, reporter, token), token);
|
||||
|
||||
//
|
||||
// Drop the preview once every chat was added. Otherwise, another click on the button
|
||||
// would silently add all of them a second time:
|
||||
//
|
||||
if (this.importResult is { Success: true, Cancelled: false })
|
||||
{
|
||||
this.preview = null;
|
||||
this.selectedArchivePath = string.Empty;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.Logger.LogInformation("The user stopped adding the chats of a backup.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isImporting = false;
|
||||
this.DisposeImportCancellation();
|
||||
this.isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelImport() => this.importCancellation?.Cancel();
|
||||
|
||||
/// <summary>
|
||||
/// Detaches the cancellation source before disposing it, so that a late click on the cancel
|
||||
/// button cannot reach a disposed source.
|
||||
/// </summary>
|
||||
private void DisposeImportCancellation()
|
||||
{
|
||||
var cancellation = this.importCancellation;
|
||||
this.importCancellation = null;
|
||||
|
||||
cancellation?.Dispose();
|
||||
}
|
||||
|
||||
private IProgress<ChatArchiveProgress> CreateImportProgressReporter()
|
||||
{
|
||||
this.lastImportRender = DateTimeOffset.MinValue;
|
||||
return new Progress<ChatArchiveProgress>(value =>
|
||||
{
|
||||
this.importProgress = value;
|
||||
|
||||
// Render at most every PROGRESS_RENDER_INTERVAL, but never skip the final state:
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (value.ProcessedChats < value.TotalChats && now - this.lastImportRender < PROGRESS_RENDER_INTERVAL)
|
||||
return;
|
||||
|
||||
this.lastImportRender = now;
|
||||
this.InvokeAsync(this.StateHasChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@using AIStudio.Tools.DataBackup
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@{
|
||||
var organizationPluginFolders = OrganizationPluginFolders;
|
||||
}
|
||||
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||
@this.ExpertNotice
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Explanation")
|
||||
</MudText>
|
||||
<MudJustifiedMarkdown Value="@T("AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager.")"/>
|
||||
|
||||
@foreach (var location in LOCATIONS)
|
||||
{
|
||||
var folder = FolderPath(location);
|
||||
<MudText Typo="Typo.h4">
|
||||
@this.FolderName(location)
|
||||
</MudText>
|
||||
<MudList T="string" Class="mb-3">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1" Style="width: 100%; min-width: 0;">
|
||||
<MudLink OnClick="@(() => this.CopyPath(folder))" Style="min-width: 0; overflow-wrap: anywhere;">
|
||||
@folder
|
||||
</MudLink>
|
||||
<MudTooltip Text="@T("Open in your file manager")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen"
|
||||
Size="Size.Medium"
|
||||
Disabled="@string.IsNullOrWhiteSpace(folder)"
|
||||
OnClick="@(() => this.OpenInFileManager(folder))"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
</MudList>
|
||||
}
|
||||
|
||||
@if (IsDataFolderInsideConfigFolder)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||
@T("On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along.")
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("What belongs into a backup")
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="@true" Bordered="@true" Striped="@true" Class="mb-3" Style="overflow-x: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Folder")</th>
|
||||
<th>@T("Path")</th>
|
||||
<th>@T("Content")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in INCLUDED_ENTRIES)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(entry.Location)</td>
|
||||
<td><code>@entry.RelativePath</code></td>
|
||||
<td>@this.Describe(entry.Kind)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("What a backup can leave out")
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="@true" Bordered="@true" Striped="@true" Class="mb-3" Style="overflow-x: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Folder")</th>
|
||||
<th>@T("Path")</th>
|
||||
<th>@T("Reason")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in EXCLUDED_ENTRIES)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(entry.Location)</td>
|
||||
<td><code>@entry.RelativePath</code></td>
|
||||
<td>@this.Describe(entry.Kind)</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var pluginFolder in organizationPluginFolders)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(DataBackupLocation.DATA_DIRECTORY)</td>
|
||||
<td><code>@pluginFolder</code></td>
|
||||
<td>@this.Describe(DataBackupEntryKind.ORGANIZATION_PLUGINS)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Good to know")
|
||||
</MudText>
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew.")
|
||||
</MudJustifiedText>
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more.")
|
||||
</MudJustifiedText>
|
||||
@if (organizationPluginFolders.Count > 0)
|
||||
{
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the data and configuration folders of AI Studio, and which of their contents belong into a backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For users who back up their computer with a tool of their own. Everything shown here comes from
|
||||
/// the same list the backups of AI Studio follow. It stays complete even when the organization does not
|
||||
/// allow backups within AI Studio: that decides about the backups AI Studio makes, not about which
|
||||
/// folders a user may copy.
|
||||
/// </remarks>
|
||||
public partial class DataFolders : MSGComponentBase
|
||||
{
|
||||
private static readonly DataBackupLocation[] LOCATIONS = [DataBackupLocation.DATA_DIRECTORY, DataBackupLocation.CONFIG_DIRECTORY];
|
||||
|
||||
private static readonly IReadOnlyList<DataBackupEntry> INCLUDED_ENTRIES = DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded).ToList();
|
||||
|
||||
private static readonly IReadOnlyList<DataBackupEntry> EXCLUDED_ENTRIES = DataBackupContents.ENTRIES.Where(entry => !entry.IsIncluded).ToList();
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<DataFolders> Logger { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tells everybody but experts where to go instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pointing to the backups of AI Studio makes sense only while the organization allows them.
|
||||
/// </remarks>
|
||||
private string ExpertNotice
|
||||
{
|
||||
get
|
||||
{
|
||||
var notice = T("The following is meant for experts who back up their computer with a tool of their own.");
|
||||
return this.SettingsManager.ConfigurationData.App.AllowUserToBackUpData
|
||||
? $"{notice} {T("To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead.")}"
|
||||
: notice;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDataFolderInsideConfigFolder =>!string.IsNullOrWhiteSpace(SettingsManager.ConfigDirectory)
|
||||
&& !string.IsNullOrWhiteSpace(SettingsManager.DataDirectory)
|
||||
&& PathTools.IsInsideDirectory(SettingsManager.ConfigDirectory, SettingsManager.DataDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// The plugins which the organization deployed among the user's own ones, as AI Studio found them when it loaded the plugins last.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> OrganizationPluginFolders => string.IsNullOrWhiteSpace(SettingsManager.DataDirectory)
|
||||
? []
|
||||
: DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), SettingsManager.DataDirectory);
|
||||
|
||||
private static string FolderPath(DataBackupLocation location) => location switch
|
||||
{
|
||||
DataBackupLocation.DATA_DIRECTORY => SettingsManager.DataDirectory ?? string.Empty,
|
||||
DataBackupLocation.CONFIG_DIRECTORY => SettingsManager.ConfigDirectory ?? string.Empty,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string FolderName(DataBackupLocation location) => location switch
|
||||
{
|
||||
DataBackupLocation.DATA_DIRECTORY => T("Data folder"),
|
||||
DataBackupLocation.CONFIG_DIRECTORY => T("Configuration folder"),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string Describe(DataBackupEntryKind kind) => kind switch
|
||||
{
|
||||
DataBackupEntryKind.SETTINGS => T("Your settings."),
|
||||
DataBackupEntryKind.WORKSPACES => T("Your chats in workspaces, along with their transcripts."),
|
||||
DataBackupEntryKind.TEMPORARY_CHATS => T("Your temporary chats, along with their transcripts."),
|
||||
DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."),
|
||||
DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."),
|
||||
DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."),
|
||||
DataBackupEntryKind.DATA_SOURCE_INDEX => T("The index of your local data sources: which files AI Studio embedded, and their text."),
|
||||
DataBackupEntryKind.VECTOR_STORES => T("The embeddings of your local data sources."),
|
||||
|
||||
DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS => T("Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."),
|
||||
DataBackupEntryKind.BUILT_IN_PLUGINS => T("Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."),
|
||||
DataBackupEntryKind.ORGANIZATION_PLUGINS => T("Plugins of your organization. Your organization brings them back."),
|
||||
DataBackupEntryKind.TEMPORARY_FILES => T("Files which AI Studio needs only for a moment and deletes again."),
|
||||
DataBackupEntryKind.DOWNLOADED_TOOLS => T("Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again."),
|
||||
DataBackupEntryKind.INTERFACE_KEYS => T("Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones."),
|
||||
DataBackupEntryKind.LOGS => T("Log files. They help when something goes wrong, but they hold none of your data."),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private async Task CopyPath(string path) => await this.RustService.CopyText2Clipboard(path);
|
||||
|
||||
private async Task OpenInFileManager(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Folder, T("The folder is not known yet.")));
|
||||
return;
|
||||
}
|
||||
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open the folder '{Path}' in the file manager.", path);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Folder, T("Could not open the folder.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Folder, string.Format(T("Could not open the folder: {0}"), issue)));
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,9 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
if (dataSource.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
if (await this.RefuseWhileIndexingPaused(T("Delete Data Source")))
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type '{1}'?"), dataSource.Name, dataSource.Type.GetDisplayName()) },
|
||||
@@ -457,6 +460,21 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the user to wait while a backup copies the indexes or a restore waits for the restart:
|
||||
/// deleting a data source discards its index, which waits for the end of the pause.
|
||||
/// </summary>
|
||||
/// <param name="title">The title of the action which waits.</param>
|
||||
/// <returns>True when the action has to wait.</returns>
|
||||
private async Task<bool> RefuseWhileIndexingPaused(string title)
|
||||
{
|
||||
if (!this.DataSourceEmbeddingService.IsIndexingPaused)
|
||||
return false;
|
||||
|
||||
await this.DialogService.ShowMessageBox(title, T("AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."), T("Close"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits a row of the table, which is either a data source or a mailbox.
|
||||
/// </summary>
|
||||
@@ -533,6 +551,9 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
if (mailbox.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
if (await this.RefuseWhileIndexingPaused(T("Delete Mailbox")))
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Are you sure you want to delete the mailbox '{0}'? Your mails stay on the server as they are. AI Studio only deletes its index of them and the stored password."), mailbox.Name) },
|
||||
|
||||
Reference in new issue
Block a user