Added data backup and restore for chats

The settings gain a data backup panel. It writes the chats of the chosen
workspaces, and optionally the temporary chats, into a single archive file
with the extension .aistudio-chats, and it imports such archives again.
The archive mirrors the chat storage, so restoring needs no conversion.

Documents the user attached are never copied into the archive. The chats
keep the absolute path of each document, which keeps them usable wherever
the documents live and avoids a second copy. Files AI Studio created itself,
such as transcripts, exist nowhere else; when the selection holds any, the
user decides whether they travel with the archive.

On import, chats which already exist, in any workspace, are either kept or
imported as additional chats with a new ID. Workspaces are merged by their
identity. An imported chat gets its own copy of every transcript the archive
did not carry, as a copied chat does, so deleting it never removes the
transcripts of the chat it came from.

An existing archive is only replaced once the new one is complete. Chats
which could not be read are reported instead of missing silently. Both
directions can be stopped at any time and report what was written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
j-erlerandClaude Opus 5.5 committed 2026-09-26 16:25:39 +02:00
1 parent 4baf21656a
commit a8e8edc028
27 files changed
+2342 -3

No files matched your search

@@ -0,0 +1,27 @@
namespace AIStudio.Components.Settings;
/// <summary>
/// One workspace as shown in the export list.
/// </summary>
public sealed class ChatArchiveWorkspaceSelection
{
/// <summary>
/// The unique identifier of the workspace.
/// </summary>
public Guid WorkspaceId { get; init; }
/// <summary>
/// The name of the workspace.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// The number of chats in this workspace.
/// </summary>
public int ChatCount { get; init; }
/// <summary>
/// Whether the user selected this workspace for the export.
/// </summary>
public bool IsSelected { get; set; }
}
@@ -0,0 +1,259 @@
@using AIStudio.Tools.ChatArchive
@inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.SettingsBackupRestore" HeaderText="@T("Data Backup & Restore")" ExpandedChanged="@this.PanelExpandedChanged">
<MudText Typo="Typo.body1" Class="mb-3">
@T("Save your chats as an archive file, for example as a backup or to move them to another computer. You can read such archives back into AI Studio at any time.")
</MudText>
<MudTabs Elevation="0" Rounded="@true" ApplyEffectsToContainer="@true" Outlined="@true" PanelClass="pa-4" ActivePanelIndexChanged="@this.ActivePanelChanged">
<MudTabPanel Text="@T("Export")" Icon="@Icons.Material.Filled.Archive" Disabled="@this.isBusy">
@if (this.isLoadingWorkspaces)
{
<MudProgressLinear Indeterminate="@true" Color="Color.Primary" Class="mb-3"/>
}
else
{
@if (!string.IsNullOrWhiteSpace(this.workspaceLoadError))
{
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
@this.workspaceLoadError
</MudAlert>
}
else if (!this.HasWorkspacesToExport)
{
<MudAlert Severity="Severity.Info" Dense="@true" Class="mb-3">
@T("There are no chats to export yet.")
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2">
@T("Choose the workspaces you want to export.")
</MudText>
}
@* The refresh button stays available even when nothing could be read,
so that a failed attempt can be repeated: *@
<MudStack Row="@true" Spacing="2" Class="mb-2">
@if (this.HasWorkspacesToExport)
{
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.SelectAll" OnClick="@(() => this.SelectAllWorkspaces(true))" Disabled="@this.isBusy">
@T("Select all")
</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Deselect" OnClick="@(() => this.SelectAllWorkspaces(false))" Disabled="@this.isBusy">
@T("Select none")
</MudButton>
}
<MudButton Variant="Variant.Text" Color="Color.Default" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@(async () => await this.LoadWorkspacesAsync())" Disabled="@this.isBusy">
@T("Refresh")
</MudButton>
</MudStack>
@if (this.HasWorkspacesToExport)
{
<MudPaper Outlined="@true" Class="pa-3 mb-3">
@foreach (var selection in this.workspaceSelections)
{
<div>
<MudCheckBox T="bool" Value="@selection.IsSelected" ValueChanged="@(value => this.WorkspaceSelectionChanged(selection, value))" Color="Color.Primary" Dense="@true" Disabled="@this.isBusy" Label="@($"{selection.Name} ({string.Format(T("{0} chats"), selection.ChatCount)})")"/>
</div>
}
@if (this.temporaryChatCount > 0)
{
<div>
<MudCheckBox T="bool" @bind-Value="@this.includeTemporaryChats" Color="Color.Primary" Dense="@true" Disabled="@this.isBusy" Label="@($"{T("Temporary chats")} ({string.Format(T("{0} chats"), this.temporaryChatCount)})")"/>
</div>
}
</MudPaper>
@* Stated permanently, because it holds for every export and decides whether
an archive is of any use on the other computer: *@
<MudAlert Severity="Severity.Info" Dense="@true" Class="mb-3">
@T("Documents you attached, such as PDF or Word files, are not written into the archive. The chats keep the location where each document is stored, so those documents stay usable as long as the other computer can reach that location.")
</MudAlert>
@if (this.isProcessing)
{
@this.ProgressWithCancel
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@this.StartExportAsync" Disabled="@(this.isBusy || !this.IsAnythingSelected)">
@string.Format(T("Export {0} chats"), this.SelectedChatCount)
</MudButton>
}
}
@if (this.exportResult is { } result)
{
@if (!result.Success)
{
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
@result.Issue
</MudAlert>
}
else if (result.Cancelled)
{
<MudAlert Severity="Severity.Info" Dense="@true" Class="mt-3">
@T("The export was stopped. No archive was created.")
</MudAlert>
}
else
{
@* A chat missing from a backup must not pass as a success: *@
<MudAlert Severity="@(result.UnreadableChats > 0 ? Severity.Warning : Severity.Success)" Dense="@true" Class="mt-3">
<MudText Typo="Typo.body2">
@string.Format(T("Exported {0} chats from {1} workspaces to: {2}"), result.ExportedChats, result.ExportedWorkspaces, result.ArchivePath)
</MudText>
@if (result.IncludedFiles > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("{0} files created by AI Studio, such as transcripts, were included."), result.IncludedFiles)
</MudText>
}
@if (result.UnreadableChats > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("{0} chats could not be read and are missing from the archive. Please try the export again later."), result.UnreadableChats)
</MudText>
}
</MudAlert>
}
}
}
</MudTabPanel>
<MudTabPanel Text="@T("Import")" Icon="@Icons.Material.Filled.Unarchive" Disabled="@this.isBusy">
<MudText Typo="Typo.body2" Class="mb-2">
@T("Choose an archive to see what it contains before anything is imported.")
</MudText>
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.FolderOpen" OnClick="@this.ChooseArchiveAsync" Disabled="@this.isBusy">
@T("Choose archive")
</MudButton>
@if (!string.IsNullOrWhiteSpace(this.selectedArchivePath))
{
<MudText Typo="Typo.body2">
@this.selectedArchivePath
</MudText>
}
</MudStack>
@if (this.importPreview is { } preview)
{
@if (!preview.Success)
{
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
@preview.Issue
</MudAlert>
}
else
{
<MudPaper Outlined="@true" Class="pa-3 mb-3">
<MudText Typo="Typo.body2" Class="mb-2">
@string.Format(T("Created on {0} with AI Studio {1}"), preview.Manifest.ExportedAt.ToLocalTime().ToString("g"), preview.Manifest.AppVersion)
</MudText>
@foreach (var workspace in preview.Manifest.Workspaces)
{
<MudText Typo="Typo.body2">
@($"{workspace.Name} ({string.Format(T("{0} chats"), workspace.Chats.Count)})")
</MudText>
}
@if (preview.Manifest.TemporaryChats.Count > 0)
{
<MudText Typo="Typo.body2">
@($"{T("Temporary chats")} ({string.Format(T("{0} chats"), preview.Manifest.TemporaryChats.Count)})")
</MudText>
}
</MudPaper>
<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("Import it as an additional chat")
</MudSelectItem>
</MudSelect>
@if (this.isProcessing)
{
@this.ProgressWithCancel
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" OnClick="@this.StartImportAsync" Disabled="@(this.isBusy || !this.CanImport)">
@string.Format(T("Import {0} chats"), preview.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("Imported {0} chats into {1} workspaces."), importOutcome.ImportedChats, importOutcome.ImportedWorkspaces)
</MudText>
@if (importOutcome.Cancelled)
{
<MudText Typo="Typo.body2">
@T("The import was stopped. The chats imported until then were kept.")
</MudText>
}
@if (importOutcome.SkippedChats > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("{0} chats already existed and were kept as they are."), importOutcome.SkippedChats)
</MudText>
}
@if (importOutcome.FailedChats > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("{0} chats could not be imported."), importOutcome.FailedChats)
</MudText>
}
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
@importOutcome.Issue
</MudAlert>
}
}
</MudTabPanel>
</MudTabs>
</ExpansionPanel>
@code {
/// <summary>
/// The progress of the running export or import. Both tabs show the same thing.
/// </summary>
private RenderFragment ProgressWithCancel => @<text>
<MudProgressLinear Color="Color.Primary" Value="@this.ProgressPercentage" Class="mb-2"/>
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">
@string.Format(T("Chat {0} of {1}"), this.progress.ProcessedChats + 1, this.progress.TotalChats)
</MudText>
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.CancelProcessing">
@T("Cancel")
</MudButton>
</MudStack>
<MudText Typo="Typo.caption">
@T("Please stay on this page until this is done. Leaving the settings stops the run.")
</MudText>
</text>;
}
@@ -0,0 +1,338 @@
using AIStudio.Dialogs;
using AIStudio.Tools.ChatArchive;
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelDataBackup : SettingsPanelBase
{
/// <summary>
/// The shortest time between two progress renders. Without this, a large archive would
/// re-render the whole page once per chat.
/// </summary>
private static readonly TimeSpan PROGRESS_RENDER_INTERVAL = TimeSpan.FromMilliseconds(100);
[Inject]
private ILogger<SettingsPanelDataBackup> Logger { get; init; } = null!;
private readonly List<ChatArchiveWorkspaceSelection> workspaceSelections = [];
private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP;
private CancellationTokenSource? cancellationTokenSource;
private ChatArchiveProgress progress;
private ChatArchiveExportResult? exportResult;
private ChatArchiveImportPreview? importPreview;
private ChatArchiveImportResult? importResult;
private DateTimeOffset lastProgressRender;
private string workspaceLoadError = string.Empty;
private string selectedArchivePath = string.Empty;
private int temporaryChatCount;
private bool includeTemporaryChats;
private bool isLoadingWorkspaces;
private bool wereWorkspacesLoaded;
/// <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>
/// Indicates that an export or import is running, which shows the progress.
/// </summary>
private bool isProcessing;
private bool HasWorkspacesToExport => this.workspaceSelections.Count > 0 || this.temporaryChatCount > 0;
private bool IsAnythingSelected => this.workspaceSelections.Any(selection => selection.IsSelected) || (this.includeTemporaryChats && this.temporaryChatCount > 0);
private int SelectedChatCount => this.workspaceSelections.Where(selection => selection.IsSelected).Sum(selection => selection.ChatCount) + (this.includeTemporaryChats ? this.temporaryChatCount : 0);
private bool CanImport => this.importPreview is { Success: true, Manifest.TotalChatCount: > 0 };
private double ProgressPercentage => this.progress.TotalChats is 0 ? 0d : this.progress.ProcessedChats * 100d / this.progress.TotalChats;
#region Overrides of MSGComponentBase
protected override void DisposeResources()
{
this.cancellationTokenSource?.Cancel();
this.DisposeCancellation();
}
#endregion
/// <summary>
/// Reads the workspaces when the user opens this panel for the first time. Doing it
/// earlier would scan the chat storage every time the settings are opened.
/// </summary>
private async Task PanelExpandedChanged(bool isExpanded)
{
if (!isExpanded || this.wereWorkspacesLoaded)
return;
// Only a successful run counts as loaded. Otherwise, a single failure would leave
// the panel empty for the rest of the session:
this.wereWorkspacesLoaded = await this.LoadWorkspacesAsync();
}
/// <summary>
/// Reads all workspaces with their chat counts, keeping the current selection.
/// </summary>
/// <returns>Whether the workspaces could be read.</returns>
private async Task<bool> LoadWorkspacesAsync()
{
this.isLoadingWorkspaces = true;
this.workspaceLoadError = string.Empty;
try
{
var previousSelection = this.workspaceSelections
.Where(selection => selection.IsSelected)
.Select(selection => selection.WorkspaceId)
.ToHashSet();
this.workspaceSelections.Clear();
var tree = await Tools.WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
foreach (var workspace in tree.Workspaces)
{
var chats = await Tools.WorkspaceBehaviour.GetWorkspaceChatsAsync(workspace.WorkspaceId);
this.workspaceSelections.Add(new()
{
WorkspaceId = workspace.WorkspaceId,
Name = workspace.Name,
ChatCount = chats.Count,
IsSelected = previousSelection.Contains(workspace.WorkspaceId),
});
}
this.workspaceSelections.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
this.temporaryChatCount = tree.TemporaryChats.Count;
return true;
}
catch (Exception exception)
{
// Reading the chat storage can fail, e.g. when the data directory sits on a
// network drive. Report it instead of showing an empty panel:
this.Logger.LogError(exception, "Failed to read the workspaces for the data backup.");
this.workspaceLoadError = string.Format(T("Your chats could not be read: {0}"), exception.Message);
this.temporaryChatCount = 0;
return false;
}
finally
{
this.isLoadingWorkspaces = false;
}
}
/// <summary>
/// Refreshes the workspaces when returning to the export, because an import in the
/// meantime might have changed them.
/// </summary>
private async Task ActivePanelChanged(int panelIndex)
{
if (this.isBusy || panelIndex is not 0)
return;
await this.LoadWorkspacesAsync();
}
private void WorkspaceSelectionChanged(ChatArchiveWorkspaceSelection selection, bool isSelected)
{
selection.IsSelected = isSelected;
this.exportResult = null;
}
private void SelectAllWorkspaces(bool isSelected)
{
foreach (var selection in this.workspaceSelections)
selection.IsSelected = isSelected;
this.includeTemporaryChats = isSelected && this.temporaryChatCount > 0;
this.exportResult = null;
}
private async Task StartExportAsync()
{
if (this.isBusy || !this.IsAnythingSelected)
return;
this.isBusy = true;
try
{
var workspaceIds = this.workspaceSelections
.Where(selection => selection.IsSelected)
.Select(selection => selection.WorkspaceId)
.ToList();
var includeChatFiles = await this.AskForChatFilesAsync(workspaceIds);
if (includeChatFiles is null)
return;
var suggestedFileName = $"ai-studio-chats-{DateTime.Now:yyyy-MM-dd}{ChatArchiveFormat.FILE_EXTENSION}";
var saveResponse = await this.RustService.SaveFile(T("Export chats"), [FileTypes.CHAT_ARCHIVE], suggestedFileName);
if (saveResponse.UserCancelled)
return;
this.exportResult = null;
this.isProcessing = true;
this.progress = new(0, this.SelectedChatCount);
this.cancellationTokenSource = new();
// Compressing on the UI thread would keep the progress bar and the cancel
// button from updating for the whole run:
var token = this.cancellationTokenSource.Token;
var reporter = this.CreateProgressReporter();
this.exportResult = await Task.Run(() => ChatArchiveExporter.ExportAsync(workspaceIds, this.includeTemporaryChats, includeChatFiles.Value, saveResponse.SaveFilePath, reporter, token), token);
}
catch (OperationCanceledException)
{
this.Logger.LogInformation("The user cancelled the chat export.");
}
finally
{
this.isProcessing = false;
this.DisposeCancellation();
this.isBusy = false;
}
}
/// <summary>
/// Asks whether the files AI Studio owns for the selected chats travel with the archive.
/// </summary>
/// <remarks>
/// Chats without such files are the normal case, and there the question has no answer
/// worth asking for. The dialog therefore only appears when the selection really contains
/// files which would otherwise be lost.
/// </remarks>
/// <param name="workspaceIds">The selected workspaces.</param>
/// <returns>Whether to include the files, or null when the user cancelled the export.</returns>
private async Task<bool?> AskForChatFilesAsync(IReadOnlyList<Guid> workspaceIds)
{
bool hasChatFiles;
try
{
// Scanning the chat storage on the UI thread would freeze the settings:
hasChatFiles = await Task.Run(() => ChatArchiveExporter.HasChatFilesAsync(workspaceIds, this.includeTemporaryChats, CancellationToken.None));
}
catch (Exception exception)
{
//
// Asking is a convenience, not a requirement. When the storage cannot be scanned,
// the export still runs and simply includes whatever it finds:
//
this.Logger.LogWarning(exception, "Failed to check the selected chats for app-owned files.");
return true;
}
if (!hasChatFiles)
return false;
var dialogReference = await this.DialogService.ShowAsync<ChatArchiveFilesDialog>(T("Include files created by AI Studio?"), DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return null;
return dialogResult.Data as bool? ?? false;
}
private async Task ChooseArchiveAsync()
{
if (this.isBusy)
return;
this.isBusy = true;
try
{
var selectionResponse = await this.RustService.SelectFile(T("Select a chat archive"), [FileTypes.CHAT_ARCHIVE]);
if (selectionResponse.UserCancelled)
return;
this.selectedArchivePath = selectionResponse.SelectedFilePath;
this.importResult = null;
this.importPreview = await ChatArchiveImporter.ReadPreviewAsync(this.selectedArchivePath, CancellationToken.None);
}
finally
{
this.isBusy = false;
}
}
private async Task StartImportAsync()
{
if (this.isBusy || !this.CanImport)
return;
this.isBusy = true;
try
{
this.importResult = null;
this.isProcessing = true;
this.progress = new(0, this.importPreview!.Manifest.TotalChatCount);
this.cancellationTokenSource = new();
var archivePath = this.selectedArchivePath;
var token = this.cancellationTokenSource.Token;
var reporter = this.CreateProgressReporter();
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, this.collisionBehavior, reporter, token), token);
//
// Drop the preview once everything was imported. Otherwise, another click on the
// import button would silently import the whole archive a second time:
//
if (this.importResult is { Success: true, Cancelled: false })
{
this.importPreview = null;
this.selectedArchivePath = string.Empty;
}
}
catch (OperationCanceledException)
{
this.Logger.LogInformation("The user cancelled the chat import.");
}
finally
{
this.isProcessing = false;
this.DisposeCancellation();
this.isBusy = false;
await this.LoadWorkspacesAsync();
}
}
private void CancelProcessing() => this.cancellationTokenSource?.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 DisposeCancellation()
{
var cancellation = this.cancellationTokenSource;
this.cancellationTokenSource = null;
cancellation?.Dispose();
}
private IProgress<ChatArchiveProgress> CreateProgressReporter()
{
this.lastProgressRender = DateTimeOffset.MinValue;
return new Progress<ChatArchiveProgress>(value =>
{
this.progress = 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.lastProgressRender < PROGRESS_RENDER_INTERVAL)
return;
this.lastProgressRender = now;
this.InvokeAsync(this.StateHasChanged);
});
}
}