mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 02:33:38 +00:00
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:
parent
4baf21656a
commit
a8e8edc028
@ -4846,6 +4846,111 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T85322
|
|||||||
-- Provider
|
-- Provider
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Provider"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Provider"
|
||||||
|
|
||||||
|
-- Import it as an additional chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1010021219"] = "Import it as an additional chat"
|
||||||
|
|
||||||
|
-- There are no chats to export yet.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1014072932"] = "There are no chats to export yet."
|
||||||
|
|
||||||
|
-- Choose an archive to see what it contains before anything is imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T105498974"] = "Choose an archive to see what it contains before anything is imported."
|
||||||
|
|
||||||
|
-- Keep the existing chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1274903302"] = "Keep the existing chat"
|
||||||
|
|
||||||
|
-- Choose the workspaces you want to export.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1308212431"] = "Choose the workspaces you want to export."
|
||||||
|
|
||||||
|
-- Refresh
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T135637716"] = "Refresh"
|
||||||
|
|
||||||
|
-- Select none
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1371105737"] = "Select none"
|
||||||
|
|
||||||
|
-- Import
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1463683828"] = "Import"
|
||||||
|
|
||||||
|
-- Created on {0} with AI Studio {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1503285113"] = "Created on {0} with AI Studio {1}"
|
||||||
|
|
||||||
|
-- Your chats could not be read: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1766061742"] = "Your chats could not be read: {0}"
|
||||||
|
|
||||||
|
-- Select all
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1794248818"] = "Select all"
|
||||||
|
|
||||||
|
-- The export was stopped. No archive was created.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2033235970"] = "The export was stopped. No archive was created."
|
||||||
|
|
||||||
|
-- When a chat already exists
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2117204104"] = "When a chat already exists"
|
||||||
|
|
||||||
|
-- Choose archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2164224046"] = "Choose archive"
|
||||||
|
|
||||||
|
-- {0} chats could not be read and are missing from the archive. Please try the export again later.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2224817655"] = "{0} chats could not be read and are missing from the archive. Please try the export again later."
|
||||||
|
|
||||||
|
-- Please stay on this page until this is done. Leaving the settings stops the run.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2285446015"] = "Please stay on this page until this is done. Leaving the settings stops the run."
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2455050681"] = "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."
|
||||||
|
|
||||||
|
-- Temporary chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2532369939"] = "Temporary chats"
|
||||||
|
|
||||||
|
-- {0} chats already existed and were kept as they are.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2657498616"] = "{0} chats already existed and were kept as they are."
|
||||||
|
|
||||||
|
-- Imported {0} chats into {1} workspaces.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2750065837"] = "Imported {0} chats into {1} workspaces."
|
||||||
|
|
||||||
|
-- Include files created by AI Studio?
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2994052834"] = "Include files created by AI Studio?"
|
||||||
|
|
||||||
|
-- Data Backup & Restore
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3317700277"] = "Data Backup & Restore"
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3341458810"] = "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."
|
||||||
|
|
||||||
|
-- Chat {0} of {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T355446621"] = "Chat {0} of {1}"
|
||||||
|
|
||||||
|
-- {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3562573794"] = "{0} chats"
|
||||||
|
|
||||||
|
-- Import {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3797505565"] = "Import {0} chats"
|
||||||
|
|
||||||
|
-- {0} chats could not be imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3877683437"] = "{0} chats could not be imported."
|
||||||
|
|
||||||
|
-- Export
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3898821075"] = "Export"
|
||||||
|
|
||||||
|
-- The import was stopped. The chats imported until then were kept.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3930319945"] = "The import was stopped. The chats imported until then were kept."
|
||||||
|
|
||||||
|
-- Export chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4010823084"] = "Export chats"
|
||||||
|
|
||||||
|
-- Exported {0} chats from {1} workspaces to: {2}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4050198329"] = "Exported {0} chats from {1} workspaces to: {2}"
|
||||||
|
|
||||||
|
-- {0} files created by AI Studio, such as transcripts, were included.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4220769876"] = "{0} files created by AI Studio, such as transcripts, were included."
|
||||||
|
|
||||||
|
-- Export {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4237744286"] = "Export {0} chats"
|
||||||
|
|
||||||
|
-- Select a chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T771250054"] = "Select a chat archive"
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T900713019"] = "Cancel"
|
||||||
|
|
||||||
-- Configure Data Sources
|
-- Configure Data Sources
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Configure Data Sources"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Configure Data Sources"
|
||||||
|
|
||||||
@ -5713,6 +5818,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T4009234360"] =
|
|||||||
-- Cancel
|
-- Cancel
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Cancel"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Cancel"
|
||||||
|
|
||||||
|
-- Include these files
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3095420519"] = "Include these files"
|
||||||
|
|
||||||
|
-- Chats only
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3556293770"] = "Chats only"
|
||||||
|
|
||||||
|
-- Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3916444544"] = "Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored."
|
||||||
|
|
||||||
|
-- Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T493903858"] = "Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content."
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T900713019"] = "Cancel"
|
||||||
|
|
||||||
-- Only text content is supported in the editing mode yet.
|
-- Only text content is supported in the editing mode yet.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Only text content is supported in the editing mode yet."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Only text content is supported in the editing mode yet."
|
||||||
|
|
||||||
@ -10693,6 +10813,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "None"
|
|||||||
-- Username & Password
|
-- Username & Password
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEEXPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||||
|
|
||||||
|
-- This file is not a chat archive.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T1673453619"] = "This file is not a chat archive."
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||||
|
|
||||||
|
-- The content list of this archive could not be read.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T315593817"] = "The content list of this archive could not be read."
|
||||||
|
|
||||||
|
-- This archive was created by a newer version of AI Studio. Please update AI Studio to import it.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T4098286364"] = "This archive was created by a newer version of AI Studio. Please update AI Studio to import it."
|
||||||
|
|
||||||
|
-- Imported workspace
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Imported workspace"
|
||||||
|
|
||||||
-- Spanish (Spain)
|
-- Spanish (Spain)
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
||||||
|
|
||||||
@ -11905,6 +12043,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Media"
|
|||||||
-- Certificate bundle
|
-- Certificate bundle
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Certificate bundle"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Certificate bundle"
|
||||||
|
|
||||||
|
-- Chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3600452921"] = "Chat archive"
|
||||||
|
|
||||||
-- Source like prefix
|
-- Source like prefix
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/MindWork AI Studio/Dialogs/ChatArchiveFilesDialog.razor
Normal file
22
app/MindWork AI Studio/Dialogs/ChatArchiveFilesDialog.razor
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
@inherits MSGComponentBase
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||||
|
@T("Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content.")
|
||||||
|
</MudJustifiedText>
|
||||||
|
<MudJustifiedText Typo="Typo.body1">
|
||||||
|
@T("Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored.")
|
||||||
|
</MudJustifiedText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@this.Cancel" Variant="Variant.Text">
|
||||||
|
@T("Cancel")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="@this.ExcludeFiles" Variant="Variant.Filled">
|
||||||
|
@T("Chats only")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="@this.IncludeFiles" Variant="Variant.Filled" Color="Color.Primary">
|
||||||
|
@T("Include these files")
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using AIStudio.Components;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace AIStudio.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks whether the files AI Studio owns for the selected chats are written into the archive.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The dialog is only shown when such files exist at all. Its result distinguishes three
|
||||||
|
/// answers: include them, export the chats alone, or do not export at all.
|
||||||
|
/// </remarks>
|
||||||
|
public partial class ChatArchiveFilesDialog : MSGComponentBase
|
||||||
|
{
|
||||||
|
[CascadingParameter]
|
||||||
|
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
private void Cancel() => this.MudDialog.Cancel();
|
||||||
|
|
||||||
|
private void ExcludeFiles() => this.MudDialog.Close(DialogResult.Ok(false));
|
||||||
|
|
||||||
|
private void IncludeFiles() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||||
|
}
|
||||||
@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
<SettingsPanelAgentContentCleaner AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
<SettingsPanelAgentContentCleaner AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
||||||
<SettingsPanelAgentAssistantAudit AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
<SettingsPanelAgentAssistantAudit AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
||||||
|
|
||||||
|
<SettingsPanelDataBackup/>
|
||||||
</MudExpansionPanels>
|
</MudExpansionPanels>
|
||||||
</InnerScrolling>
|
</InnerScrolling>
|
||||||
</div>
|
</div>
|
||||||
@ -4848,6 +4848,111 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T85322
|
|||||||
-- Provider
|
-- Provider
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Anbieter"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Anbieter"
|
||||||
|
|
||||||
|
-- Import it as an additional chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1010021219"] = "Als zusätzlichen Chat importieren"
|
||||||
|
|
||||||
|
-- There are no chats to export yet.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1014072932"] = "Es gibt noch keine Chats zum Exportieren."
|
||||||
|
|
||||||
|
-- Choose an archive to see what it contains before anything is imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T105498974"] = "Wählen Sie ein Archiv aus, um seinen Inhalt zu sehen, bevor etwas importiert wird."
|
||||||
|
|
||||||
|
-- Keep the existing chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1274903302"] = "Vorhandenen Chat behalten"
|
||||||
|
|
||||||
|
-- Choose the workspaces you want to export.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1308212431"] = "Wählen Sie die Arbeitsbereiche aus, die exportiert werden sollen."
|
||||||
|
|
||||||
|
-- Refresh
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T135637716"] = "Aktualisieren"
|
||||||
|
|
||||||
|
-- Select none
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1371105737"] = "Auswahl aufheben"
|
||||||
|
|
||||||
|
-- Import
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1463683828"] = "Importieren"
|
||||||
|
|
||||||
|
-- Created on {0} with AI Studio {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1503285113"] = "Erstellt am {0} mit AI Studio {1}"
|
||||||
|
|
||||||
|
-- Your chats could not be read: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1766061742"] = "Ihre Chats konnten nicht gelesen werden: {0}"
|
||||||
|
|
||||||
|
-- Select all
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1794248818"] = "Alle auswählen"
|
||||||
|
|
||||||
|
-- The export was stopped. No archive was created.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2033235970"] = "Der Export wurde abgebrochen. Es wurde kein Archiv erstellt."
|
||||||
|
|
||||||
|
-- When a chat already exists
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2117204104"] = "Wenn ein Chat bereits vorhanden ist"
|
||||||
|
|
||||||
|
-- Choose archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2164224046"] = "Archiv auswählen"
|
||||||
|
|
||||||
|
-- {0} chats could not be read and are missing from the archive. Please try the export again later.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2224817655"] = "{0} Chats konnten nicht gelesen werden und fehlen im Archiv. Bitte wiederholen Sie den Export später."
|
||||||
|
|
||||||
|
-- Please stay on this page until this is done. Leaving the settings stops the run.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2285446015"] = "Bitte bleiben Sie auf dieser Seite, bis der Vorgang abgeschlossen ist. Beim Verlassen der Einstellungen wird er abgebrochen."
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2455050681"] = "Speichern Sie Ihre Chats als Archivdatei, zum Beispiel als Sicherung oder um sie auf einen anderen Rechner zu übertragen. Solche Archive lassen sich jederzeit wieder in AI Studio einlesen."
|
||||||
|
|
||||||
|
-- Temporary chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2532369939"] = "Temporäre Chats"
|
||||||
|
|
||||||
|
-- {0} chats already existed and were kept as they are.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2657498616"] = "{0} Chats waren bereits vorhanden und wurden unverändert behalten."
|
||||||
|
|
||||||
|
-- Imported {0} chats into {1} workspaces.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2750065837"] = "{0} Chats wurden in {1} Arbeitsbereiche importiert."
|
||||||
|
|
||||||
|
-- Include files created by AI Studio?
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2994052834"] = "Von AI Studio erzeugte Dateien mitsichern?"
|
||||||
|
|
||||||
|
-- Data Backup & Restore
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3317700277"] = "Datensicherung & Wiederherstellung"
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3341458810"] = "Angehängte Dokumente wie PDF- oder Word-Dateien werden nicht ins Archiv geschrieben. Die Chats behalten den Ablageort jedes Dokuments. Solange dieser vom anderen Rechner aus erreichbar ist, bleiben die Dokumente nutzbar."
|
||||||
|
|
||||||
|
-- Chat {0} of {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T355446621"] = "Chat {0} von {1}"
|
||||||
|
|
||||||
|
-- {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3562573794"] = "{0} Chats"
|
||||||
|
|
||||||
|
-- Import {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3797505565"] = "{0} Chats importieren"
|
||||||
|
|
||||||
|
-- {0} chats could not be imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3877683437"] = "{0} Chats konnten nicht importiert werden."
|
||||||
|
|
||||||
|
-- Export
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3898821075"] = "Exportieren"
|
||||||
|
|
||||||
|
-- The import was stopped. The chats imported until then were kept.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3930319945"] = "Der Import wurde abgebrochen. Die bis dahin importierten Chats wurden behalten."
|
||||||
|
|
||||||
|
-- Export chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4010823084"] = "Chats exportieren"
|
||||||
|
|
||||||
|
-- Exported {0} chats from {1} workspaces to: {2}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4050198329"] = "{0} Chats aus {1} Arbeitsbereichen wurden exportiert nach: {2}"
|
||||||
|
|
||||||
|
-- {0} files created by AI Studio, such as transcripts, were included.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4220769876"] = "{0} von AI Studio erzeugte Dateien wie Transkripte wurden mitgesichert."
|
||||||
|
|
||||||
|
-- Export {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4237744286"] = "{0} Chats exportieren"
|
||||||
|
|
||||||
|
-- Select a chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T771250054"] = "Chat-Archiv auswählen"
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T900713019"] = "Abbrechen"
|
||||||
|
|
||||||
-- Configure Data Sources
|
-- Configure Data Sources
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Datenquellen konfigurieren"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Datenquellen konfigurieren"
|
||||||
|
|
||||||
@ -5715,6 +5820,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T4009234360"] =
|
|||||||
-- Cancel
|
-- Cancel
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Abbrechen"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Abbrechen"
|
||||||
|
|
||||||
|
-- Include these files
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3095420519"] = "Dateien mitsichern"
|
||||||
|
|
||||||
|
-- Chats only
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3556293770"] = "Nur Chats"
|
||||||
|
|
||||||
|
-- Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3916444544"] = "Selbst angehängte Dokumente wie PDF- oder Word-Dateien sind nie Teil des Archivs. Die Chats behalten den Ablageort jedes Dokuments."
|
||||||
|
|
||||||
|
-- Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T493903858"] = "Einige der ausgewählten Chats besitzen Dateien, die AI Studio selbst erzeugt hat, etwa Transkripte von Audio- und Videodateien. Diese Dateien existieren nirgendwo sonst. Ohne sie verlieren die betroffenen Chats Inhalte."
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T900713019"] = "Abbrechen"
|
||||||
|
|
||||||
-- Only text content is supported in the editing mode yet.
|
-- Only text content is supported in the editing mode yet.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Im Bearbeitungsmodus wird bisher nur Textinhalt unterstützt."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Im Bearbeitungsmodus wird bisher nur Textinhalt unterstützt."
|
||||||
|
|
||||||
@ -10695,6 +10815,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "Keine
|
|||||||
-- Username & Password
|
-- Username & Password
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Benutzername & Passwort"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Benutzername & Passwort"
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEEXPORTER::T2350673880"] = "Unerwarteter Fehler: {0}"
|
||||||
|
|
||||||
|
-- This file is not a chat archive.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T1673453619"] = "Diese Datei ist kein Chat-Archiv."
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unerwarteter Fehler: {0}"
|
||||||
|
|
||||||
|
-- The content list of this archive could not be read.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T315593817"] = "Das Inhaltsverzeichnis dieses Archivs konnte nicht gelesen werden."
|
||||||
|
|
||||||
|
-- This archive was created by a newer version of AI Studio. Please update AI Studio to import it.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T4098286364"] = "Dieses Archiv wurde mit einer neueren Version von AI Studio erstellt. Bitte aktualisieren Sie AI Studio, um es zu importieren."
|
||||||
|
|
||||||
|
-- Imported workspace
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Importierter Arbeitsbereich"
|
||||||
|
|
||||||
-- Spanish (Spain)
|
-- Spanish (Spain)
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanisch (Spanien)"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanisch (Spanien)"
|
||||||
|
|
||||||
@ -11907,6 +12045,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Medien"
|
|||||||
-- Certificate bundle
|
-- Certificate bundle
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Zertifikatsbündel"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Zertifikatsbündel"
|
||||||
|
|
||||||
|
-- Chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3600452921"] = "Chat-Archiv"
|
||||||
|
|
||||||
-- Source like prefix
|
-- Source like prefix
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code ähnlicher Prefix"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code ähnlicher Prefix"
|
||||||
|
|
||||||
|
|||||||
@ -4848,6 +4848,111 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T85322
|
|||||||
-- Provider
|
-- Provider
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Provider"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELCONFIDENCE::T900237532"] = "Provider"
|
||||||
|
|
||||||
|
-- Import it as an additional chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1010021219"] = "Import it as an additional chat"
|
||||||
|
|
||||||
|
-- There are no chats to export yet.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1014072932"] = "There are no chats to export yet."
|
||||||
|
|
||||||
|
-- Choose an archive to see what it contains before anything is imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T105498974"] = "Choose an archive to see what it contains before anything is imported."
|
||||||
|
|
||||||
|
-- Keep the existing chat
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1274903302"] = "Keep the existing chat"
|
||||||
|
|
||||||
|
-- Choose the workspaces you want to export.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1308212431"] = "Choose the workspaces you want to export."
|
||||||
|
|
||||||
|
-- Refresh
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T135637716"] = "Refresh"
|
||||||
|
|
||||||
|
-- Select none
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1371105737"] = "Select none"
|
||||||
|
|
||||||
|
-- Import
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1463683828"] = "Import"
|
||||||
|
|
||||||
|
-- Created on {0} with AI Studio {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1503285113"] = "Created on {0} with AI Studio {1}"
|
||||||
|
|
||||||
|
-- Your chats could not be read: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1766061742"] = "Your chats could not be read: {0}"
|
||||||
|
|
||||||
|
-- Select all
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T1794248818"] = "Select all"
|
||||||
|
|
||||||
|
-- The export was stopped. No archive was created.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2033235970"] = "The export was stopped. No archive was created."
|
||||||
|
|
||||||
|
-- When a chat already exists
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2117204104"] = "When a chat already exists"
|
||||||
|
|
||||||
|
-- Choose archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2164224046"] = "Choose archive"
|
||||||
|
|
||||||
|
-- {0} chats could not be read and are missing from the archive. Please try the export again later.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2224817655"] = "{0} chats could not be read and are missing from the archive. Please try the export again later."
|
||||||
|
|
||||||
|
-- Please stay on this page until this is done. Leaving the settings stops the run.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2285446015"] = "Please stay on this page until this is done. Leaving the settings stops the run."
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2455050681"] = "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."
|
||||||
|
|
||||||
|
-- Temporary chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2532369939"] = "Temporary chats"
|
||||||
|
|
||||||
|
-- {0} chats already existed and were kept as they are.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2657498616"] = "{0} chats already existed and were kept as they are."
|
||||||
|
|
||||||
|
-- Imported {0} chats into {1} workspaces.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2750065837"] = "Imported {0} chats into {1} workspaces."
|
||||||
|
|
||||||
|
-- Include files created by AI Studio?
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T2994052834"] = "Include files created by AI Studio?"
|
||||||
|
|
||||||
|
-- Data Backup & Restore
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3317700277"] = "Data Backup & Restore"
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3341458810"] = "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."
|
||||||
|
|
||||||
|
-- Chat {0} of {1}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T355446621"] = "Chat {0} of {1}"
|
||||||
|
|
||||||
|
-- {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3562573794"] = "{0} chats"
|
||||||
|
|
||||||
|
-- Import {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3797505565"] = "Import {0} chats"
|
||||||
|
|
||||||
|
-- {0} chats could not be imported.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3877683437"] = "{0} chats could not be imported."
|
||||||
|
|
||||||
|
-- Export
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3898821075"] = "Export"
|
||||||
|
|
||||||
|
-- The import was stopped. The chats imported until then were kept.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T3930319945"] = "The import was stopped. The chats imported until then were kept."
|
||||||
|
|
||||||
|
-- Export chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4010823084"] = "Export chats"
|
||||||
|
|
||||||
|
-- Exported {0} chats from {1} workspaces to: {2}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4050198329"] = "Exported {0} chats from {1} workspaces to: {2}"
|
||||||
|
|
||||||
|
-- {0} files created by AI Studio, such as transcripts, were included.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4220769876"] = "{0} files created by AI Studio, such as transcripts, were included."
|
||||||
|
|
||||||
|
-- Export {0} chats
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T4237744286"] = "Export {0} chats"
|
||||||
|
|
||||||
|
-- Select a chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T771250054"] = "Select a chat archive"
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATABACKUP::T900713019"] = "Cancel"
|
||||||
|
|
||||||
-- Configure Data Sources
|
-- Configure Data Sources
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Configure Data Sources"
|
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELDATASOURCES::T476193103"] = "Configure Data Sources"
|
||||||
|
|
||||||
@ -5715,6 +5820,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T4009234360"] =
|
|||||||
-- Cancel
|
-- Cancel
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Cancel"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::BATCHPROCESSINGRESUMEDIALOG::T900713019"] = "Cancel"
|
||||||
|
|
||||||
|
-- Include these files
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3095420519"] = "Include these files"
|
||||||
|
|
||||||
|
-- Chats only
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3556293770"] = "Chats only"
|
||||||
|
|
||||||
|
-- Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T3916444544"] = "Documents you attached yourself, such as PDF or Word files, are never part of the archive. The chats keep the location where each document is stored."
|
||||||
|
|
||||||
|
-- Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T493903858"] = "Some of the selected chats have files which AI Studio created itself, such as transcripts of audio and video files. These files exist nowhere else, so without them those chats lose content."
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATARCHIVEFILESDIALOG::T900713019"] = "Cancel"
|
||||||
|
|
||||||
-- Only text content is supported in the editing mode yet.
|
-- Only text content is supported in the editing mode yet.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Only text content is supported in the editing mode yet."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T1352914344"] = "Only text content is supported in the editing mode yet."
|
||||||
|
|
||||||
@ -10695,6 +10815,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "No au
|
|||||||
-- Username & Password
|
-- Username & Password
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEEXPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||||
|
|
||||||
|
-- This file is not a chat archive.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T1673453619"] = "This file is not a chat archive."
|
||||||
|
|
||||||
|
-- Unexpected error: {0}
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||||
|
|
||||||
|
-- The content list of this archive could not be read.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T315593817"] = "The content list of this archive could not be read."
|
||||||
|
|
||||||
|
-- This archive was created by a newer version of AI Studio. Please update AI Studio to import it.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T4098286364"] = "This archive was created by a newer version of AI Studio. Please update AI Studio to import it."
|
||||||
|
|
||||||
|
-- Imported workspace
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Imported workspace"
|
||||||
|
|
||||||
-- Spanish (Spain)
|
-- Spanish (Spain)
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
||||||
|
|
||||||
@ -11907,6 +12045,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Media"
|
|||||||
-- Certificate bundle
|
-- Certificate bundle
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Certificate bundle"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3543954504"] = "Certificate bundle"
|
||||||
|
|
||||||
|
-- Chat archive
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3600452921"] = "Chat archive"
|
||||||
|
|
||||||
-- Source like prefix
|
-- Source like prefix
|
||||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
|
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
using AIStudio.Chat;
|
||||||
|
|
||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visits every file path stored inside a chat thread. Export and import use this to
|
||||||
|
/// translate between absolute paths on this machine and archive-relative paths.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChatArchiveAttachmentPaths
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces every file path of the given chat thread by the result of the rewrite function.
|
||||||
|
/// Paths which the rewrite function returns unchanged stay untouched.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chat">The chat thread to process. It is modified in place.</param>
|
||||||
|
/// <param name="rewrite">Maps an existing path to its replacement.</param>
|
||||||
|
public static void Rewrite(ChatThread chat, Func<string, string> rewrite)
|
||||||
|
{
|
||||||
|
foreach (var block in chat.Blocks)
|
||||||
|
{
|
||||||
|
if (block.Content is not { } content)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
RewriteAttachments(content.FileAttachments, rewrite);
|
||||||
|
|
||||||
|
// Images might reference a file on this machine as well:
|
||||||
|
if (content is ContentImage { SourceType: ContentImageSource.LOCAL_PATH } image && !string.IsNullOrWhiteSpace(image.Source))
|
||||||
|
image.Source = rewrite(image.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcripts which were prepared for the composer but not sent yet:
|
||||||
|
for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
|
||||||
|
{
|
||||||
|
var transcript = chat.PendingMediaTranscripts[index];
|
||||||
|
if (string.IsNullOrWhiteSpace(transcript.FilePath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
chat.PendingMediaTranscripts[index] = transcript with { FilePath = rewrite(transcript.FilePath) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RewriteAttachments(List<FileAttachment> attachments, Func<string, string> rewrite)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < attachments.Count; index++)
|
||||||
|
{
|
||||||
|
var attachment = attachments[index];
|
||||||
|
if (string.IsNullOrWhiteSpace(attachment.FilePath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var rewrittenPath = rewrite(attachment.FilePath);
|
||||||
|
if (rewrittenPath == attachment.FilePath)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
attachments[index] = attachment switch
|
||||||
|
{
|
||||||
|
ManagedTranscriptAttachment managed => managed with { FilePath = rewrittenPath },
|
||||||
|
FileAttachmentImage image => image with { FilePath = rewrittenPath },
|
||||||
|
|
||||||
|
_ => attachment with { FilePath = rewrittenPath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the files AI Studio keeps inside a chat directory itself, such as transcripts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Documents the user attaches are never copied into a chat directory: they stay where the
|
||||||
|
/// user put them and the chat only stores their path. Everything below a chat directory was
|
||||||
|
/// therefore written by AI Studio, which makes the directory contents a reliable definition
|
||||||
|
/// of "the app's own files" — minus the two files the chat storage owns itself.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ChatArchiveChatFiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Collects the app-owned files of one chat.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chatDirectory">The directory of the chat.</param>
|
||||||
|
/// <returns>The absolute paths of the files, keyed by their chat-relative path.</returns>
|
||||||
|
public static Dictionary<string, string> Collect(string chatDirectory)
|
||||||
|
{
|
||||||
|
var files = new Dictionary<string, string>(PathTools.COMPARER);
|
||||||
|
if (!Directory.Exists(chatDirectory))
|
||||||
|
return files;
|
||||||
|
|
||||||
|
foreach (var filePath in Directory.EnumerateFiles(chatDirectory, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
// Skip the temporary files the chat storage writes while saving a chat:
|
||||||
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
if (fileName.StartsWith('.') && fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var chatRelativePath = Path.GetRelativePath(chatDirectory, filePath).Replace(Path.DirectorySeparatorChar, ChatArchiveFormat.ENTRY_SEPARATOR);
|
||||||
|
|
||||||
|
// The chat itself is always exported and is not one of its own attachments:
|
||||||
|
if (chatRelativePath.Equals(ChatArchiveFormat.THREAD_FILE_NAME, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
chatRelativePath.Equals(ChatArchiveFormat.NAME_FILE_NAME, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
files[chatRelativePath] = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether at least one of the given chats owns a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This stops at the first file found, so that asking the question stays cheap even for
|
||||||
|
/// a large chat storage.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="chatDirectories">The directories of the chats to check.</param>
|
||||||
|
/// <param name="token">Cancels the search.</param>
|
||||||
|
/// <returns>Whether any app-owned file exists.</returns>
|
||||||
|
public static bool HasAny(IEnumerable<string> chatDirectories, CancellationToken token)
|
||||||
|
{
|
||||||
|
foreach (var chatDirectory in chatDirectories)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (Collect(chatDirectory).Count > 0)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines what the import does when a chat from the archive already exists.
|
||||||
|
/// </summary>
|
||||||
|
public enum ChatArchiveCollisionBehavior
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the existing chat and ignores the one from the archive.
|
||||||
|
/// </summary>
|
||||||
|
SKIP,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports the chat from the archive as an additional chat.
|
||||||
|
/// </summary>
|
||||||
|
IMPORT_AS_COPY,
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outcome of a chat archive export.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Success">Whether the archive was written completely.</param>
|
||||||
|
/// <param name="ArchivePath">The path of the written archive.</param>
|
||||||
|
/// <param name="ExportedWorkspaces">The number of exported workspaces.</param>
|
||||||
|
/// <param name="ExportedChats">The number of exported chats.</param>
|
||||||
|
/// <param name="IncludedFiles">The number of app-owned files, such as transcripts, which were included in the archive.</param>
|
||||||
|
/// <param name="UnreadableChats">The number of selected chats which could not be read and are missing from the archive.</param>
|
||||||
|
/// <param name="Issue">The reason why the export failed, if it did.</param>
|
||||||
|
/// <param name="Cancelled">Whether the user stopped the export. No archive is left behind then.</param>
|
||||||
|
public sealed record ChatArchiveExportResult(
|
||||||
|
bool Success,
|
||||||
|
string ArchivePath,
|
||||||
|
int ExportedWorkspaces,
|
||||||
|
int ExportedChats,
|
||||||
|
int IncludedFiles,
|
||||||
|
int UnreadableChats,
|
||||||
|
string Issue,
|
||||||
|
bool Cancelled);
|
||||||
329
app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveExporter.cs
Normal file
329
app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveExporter.cs
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using AIStudio.Tools.Metadata;
|
||||||
|
using AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes selected workspaces and temporary chats into a chat archive.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChatArchiveExporter
|
||||||
|
{
|
||||||
|
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(ChatArchiveExporter));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File types which are compressed already. Deflating them again costs time without
|
||||||
|
/// making the archive smaller.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly HashSet<string> ALREADY_COMPRESSED_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".tiff",
|
||||||
|
".mp3", ".m4a", ".m4b", ".aac", ".ogg", ".opus", ".flac", ".wma",
|
||||||
|
".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".wmv", ".flv",
|
||||||
|
".zip", ".7z", ".gz", ".bz2", ".xz", ".rar",
|
||||||
|
".pdf", ".docx", ".xlsx", ".pptx", ".odt", ".odp",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly int BUFFER_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatArchiveExporter).Namespace, nameof(ChatArchiveExporter));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports the given workspaces and, when requested, all temporary chats into one archive.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workspaceIds">The workspaces to export.</param>
|
||||||
|
/// <param name="includeTemporaryChats">Whether to include all temporary chats.</param>
|
||||||
|
/// <param name="includeChatFiles">Whether to include the app-owned files of the chats, such as transcripts.</param>
|
||||||
|
/// <param name="archivePath">The target path of the archive. An existing file is replaced once the new archive is complete.</param>
|
||||||
|
/// <param name="progress">Receives the export progress.</param>
|
||||||
|
/// <param name="token">Cancels the export. A cancelled export leaves no archive behind and an existing one untouched.</param>
|
||||||
|
/// <returns>The export result.</returns>
|
||||||
|
public static async Task<ChatArchiveExportResult> ExportAsync(IReadOnlyList<Guid> workspaceIds, bool includeTemporaryChats, bool includeChatFiles, string archivePath, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The archive is written next to its target and only takes its place once complete.
|
||||||
|
// Writing over the target directly would destroy the backup it replaces the moment the
|
||||||
|
// export starts, and a failed or cancelled export would then leave none at all:
|
||||||
|
//
|
||||||
|
var temporaryArchivePath = $"{archivePath}.{Guid.NewGuid():N}.tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Collect everything to export first, so that we know the total amount of
|
||||||
|
// work before we start writing the archive:
|
||||||
|
//
|
||||||
|
var tree = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||||
|
var selectedWorkspaces = new List<(Guid WorkspaceId, string Name, IReadOnlyList<WorkspaceTreeChat> Chats)>();
|
||||||
|
foreach (var workspaceId in workspaceIds)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var workspace = tree.Workspaces.FirstOrDefault(candidate => candidate.WorkspaceId == workspaceId);
|
||||||
|
if (workspace.WorkspaceId == Guid.Empty)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("Skipping workspace '{WorkspaceId}' for the export, because it is unknown.", workspaceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspaceId);
|
||||||
|
selectedWorkspaces.Add((workspaceId, workspace.Name, chats));
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<WorkspaceTreeChat> temporaryChats = includeTemporaryChats ? tree.TemporaryChats : [];
|
||||||
|
var totalChats = selectedWorkspaces.Sum(workspace => workspace.Chats.Count) + temporaryChats.Count;
|
||||||
|
var processedChats = 0;
|
||||||
|
var unreadableChats = 0;
|
||||||
|
|
||||||
|
var manifestWorkspaces = new List<ChatArchiveManifestWorkspace>();
|
||||||
|
List<ChatArchiveManifestChat> manifestTemporaryChats;
|
||||||
|
|
||||||
|
await using (var archiveStream = new FileStream(temporaryArchivePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||||
|
{
|
||||||
|
using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create);
|
||||||
|
|
||||||
|
// Workspace chats and temporary chats differ only in where they are stored:
|
||||||
|
async Task<List<ChatArchiveManifestChat>> ExportChatsAsync(string entryPrefix, Guid workspaceId, IReadOnlyList<WorkspaceTreeChat> chats)
|
||||||
|
{
|
||||||
|
var manifestChats = new List<ChatArchiveManifestChat>();
|
||||||
|
foreach (var chat in chats)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
progress?.Report(new(processedChats, totalChats));
|
||||||
|
processedChats++;
|
||||||
|
|
||||||
|
var manifestChat = await ExportChatAsync(archive, entryPrefix, workspaceId, chat, includeChatFiles, token);
|
||||||
|
if (manifestChat is not null)
|
||||||
|
manifestChats.Add(manifestChat);
|
||||||
|
else
|
||||||
|
unreadableChats++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifestChats;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var workspace in selectedWorkspaces)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var workspaceEntryPrefix = $"{ChatArchiveFormat.WORKSPACES_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}{workspace.WorkspaceId}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
await WriteTextEntryAsync(archive, $"{workspaceEntryPrefix}{ChatArchiveFormat.NAME_FILE_NAME}", workspace.Name, token);
|
||||||
|
|
||||||
|
manifestWorkspaces.Add(new()
|
||||||
|
{
|
||||||
|
WorkspaceId = workspace.WorkspaceId,
|
||||||
|
Name = workspace.Name,
|
||||||
|
Chats = await ExportChatsAsync(workspaceEntryPrefix, workspace.WorkspaceId, workspace.Chats),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryChatsEntryPrefix = $"{ChatArchiveFormat.TEMPORARY_CHATS_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
manifestTemporaryChats = await ExportChatsAsync(temporaryChatsEntryPrefix, Guid.Empty, temporaryChats);
|
||||||
|
|
||||||
|
var manifest = new ChatArchiveManifest
|
||||||
|
{
|
||||||
|
FormatVersion = ChatArchiveFormat.FORMAT_VERSION,
|
||||||
|
ExportedAt = DateTimeOffset.UtcNow,
|
||||||
|
AppVersion = Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? "unknown",
|
||||||
|
Workspaces = manifestWorkspaces,
|
||||||
|
TemporaryChats = manifestTemporaryChats,
|
||||||
|
};
|
||||||
|
|
||||||
|
var manifestJson = JsonSerializer.Serialize(manifest, WorkspaceBehaviour.JSON_OPTIONS);
|
||||||
|
await WriteTextEntryAsync(archive, ChatArchiveFormat.MANIFEST_FILE_NAME, manifestJson, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
File.Move(temporaryArchivePath, archivePath, overwrite: true);
|
||||||
|
|
||||||
|
progress?.Report(new(processedChats, totalChats));
|
||||||
|
|
||||||
|
var exportedChats = manifestWorkspaces.SelectMany(workspace => workspace.Chats).Concat(manifestTemporaryChats).ToList();
|
||||||
|
LOG.LogInformation("Exported {ChatCount} chats from {WorkspaceCount} workspaces to the archive '{ArchivePath}'.", exportedChats.Count, manifestWorkspaces.Count, archivePath);
|
||||||
|
|
||||||
|
return new(
|
||||||
|
true,
|
||||||
|
archivePath,
|
||||||
|
manifestWorkspaces.Count,
|
||||||
|
exportedChats.Count,
|
||||||
|
exportedChats.Sum(chat => chat.IncludedFiles),
|
||||||
|
unreadableChats,
|
||||||
|
string.Empty,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
TryDeleteArchive(temporaryArchivePath);
|
||||||
|
LOG.LogInformation("The user cancelled the export to '{ArchivePath}'. The incomplete archive was removed.", archivePath);
|
||||||
|
return new(true, archivePath, 0, 0, 0, 0, string.Empty, true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
TryDeleteArchive(temporaryArchivePath);
|
||||||
|
LOG.LogError(exception, "Failed to export chats to the archive '{ArchivePath}'.", archivePath);
|
||||||
|
return new(false, archivePath, 0, 0, 0, 0, string.Format(TB("Unexpected error: {0}"), exception.Message), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the given selection contains any app-owned file which the export
|
||||||
|
/// could include, so that the user is only asked about them when there are any.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workspaceIds">The workspaces of the selection.</param>
|
||||||
|
/// <param name="includeTemporaryChats">Whether the selection includes all temporary chats.</param>
|
||||||
|
/// <param name="token">Cancels the search.</param>
|
||||||
|
/// <returns>Whether at least one chat of the selection owns a file.</returns>
|
||||||
|
public static async Task<bool> HasChatFilesAsync(IReadOnlyList<Guid> workspaceIds, bool includeTemporaryChats, CancellationToken token)
|
||||||
|
{
|
||||||
|
var tree = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||||
|
foreach (var workspaceId in workspaceIds)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspaceId);
|
||||||
|
if (ChatArchiveChatFiles.HasAny(chats.Select(chat => chat.ChatPath), token))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return includeTemporaryChats && ChatArchiveChatFiles.HasAny(tree.TemporaryChats.Select(chat => chat.ChatPath), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one chat into the archive: its name, optionally the files AI Studio owns for it,
|
||||||
|
/// and its chat thread.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Documents the user attached are never written into the archive. Their paths stay
|
||||||
|
/// absolute in the chat thread, which keeps them working wherever the documents live,
|
||||||
|
/// for example on a document server, and keeps the archive free of copies of them.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>The manifest entry for the chat, or null when the chat could not be read.</returns>
|
||||||
|
private static async Task<ChatArchiveManifestChat?> ExportChatAsync(ZipArchive archive, string entryPrefix, Guid workspaceId, WorkspaceTreeChat chat, bool includeChatFiles, CancellationToken token)
|
||||||
|
{
|
||||||
|
var thread = await WorkspaceBehaviour.LoadChatAsync(new(workspaceId, chat.ChatId));
|
||||||
|
if (thread is null)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("Skipping chat '{ChatId}' of workspace '{WorkspaceId}' for the export, because it could not be read.", chat.ChatId, workspaceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatDirectory = chat.ChatPath;
|
||||||
|
var chatEntryPrefix = $"{entryPrefix}{chat.ChatId}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
await WriteTextEntryAsync(archive, $"{chatEntryPrefix}{ChatArchiveFormat.NAME_FILE_NAME}", chat.Name, token);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Write the files before rewriting any path: only a file which is really in the
|
||||||
|
// archive may become an archive-relative path. Otherwise a file which could not be
|
||||||
|
// read would leave a relative path behind, which the import cannot resolve.
|
||||||
|
//
|
||||||
|
var includedFiles = new Dictionary<string, string>(PathTools.COMPARER);
|
||||||
|
if (includeChatFiles)
|
||||||
|
{
|
||||||
|
foreach (var (chatRelativePath, filePath) in ChatArchiveChatFiles.Collect(chatDirectory))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WriteFileEntryAsync(archive, $"{chatEntryPrefix}{chatRelativePath}", filePath, token);
|
||||||
|
includedFiles[chatRelativePath] = filePath;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogWarning(exception, "Failed to add the file '{FilePath}' of chat '{ChatId}' to the archive.", filePath, chat.ChatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatArchiveAttachmentPaths.Rewrite(thread, originalPath =>
|
||||||
|
TryGetChatRelativePath(chatDirectory, originalPath, out var relativePath) && includedFiles.ContainsKey(relativePath)
|
||||||
|
? relativePath
|
||||||
|
: originalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var threadJson = JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS);
|
||||||
|
await WriteTextEntryAsync(archive, $"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", threadJson, token);
|
||||||
|
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
ChatId = chat.ChatId,
|
||||||
|
Name = chat.Name,
|
||||||
|
LastEditTime = chat.LastEditTime,
|
||||||
|
IncludedFiles = includedFiles.Count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the given path points into the chat directory and returns the
|
||||||
|
/// archive-relative path for it.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetChatRelativePath(string chatDirectory, string path, out string relativePath)
|
||||||
|
{
|
||||||
|
relativePath = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relative = Path.GetRelativePath(Path.GetFullPath(chatDirectory), Path.GetFullPath(path));
|
||||||
|
if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathTools.COMPARISON))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
relativePath = relative.Replace(Path.DirectorySeparatorChar, ChatArchiveFormat.ENTRY_SEPARATOR);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogWarning(exception, "Could not determine whether the path '{Path}' belongs to the chat directory '{ChatDirectory}'.", path, chatDirectory);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteTextEntryAsync(ZipArchive archive, string entryName, string content, CancellationToken token)
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||||
|
await using var entryStream = entry.Open();
|
||||||
|
await using var writer = new StreamWriter(entryStream, new UTF8Encoding(false));
|
||||||
|
await writer.WriteAsync(content.AsMemory(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteFileEntryAsync(ZipArchive archive, string entryName, string filePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
// Compressing an already compressed attachment costs time for nothing:
|
||||||
|
var compressionLevel = ALREADY_COMPRESSED_EXTENSIONS.Contains(Path.GetExtension(filePath))
|
||||||
|
? CompressionLevel.Fastest
|
||||||
|
: CompressionLevel.Optimal;
|
||||||
|
|
||||||
|
var entry = archive.CreateEntry(entryName, compressionLevel);
|
||||||
|
|
||||||
|
// The ZIP format cannot store timestamps before 1980:
|
||||||
|
var lastWriteTime = File.GetLastWriteTime(filePath);
|
||||||
|
if (lastWriteTime.Year >= 1980)
|
||||||
|
entry.LastWriteTime = lastWriteTime;
|
||||||
|
|
||||||
|
await using var entryStream = entry.Open();
|
||||||
|
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
await fileStream.CopyToAsync(entryStream, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteArchive(string archivePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(archivePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(archivePath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogWarning(exception, "Failed to delete the incomplete archive '{ArchivePath}'.", archivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central definition of the chat archive format. A chat archive is a ZIP file which mirrors
|
||||||
|
/// the chat storage layout, so that exported chats can be restored without any conversion.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChatArchiveFormat
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The archive format version written by this app version. The importer refuses archives
|
||||||
|
/// with a higher version, because it cannot know what those archives contain.
|
||||||
|
/// </summary>
|
||||||
|
public const int FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file extension used for chat archives.
|
||||||
|
/// </summary>
|
||||||
|
public const string FILE_EXTENSION = ".aistudio-chats";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The manifest file at the archive root. It describes what the archive contains.
|
||||||
|
/// </summary>
|
||||||
|
public const string MANIFEST_FILE_NAME = "manifest.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The archive directory containing all exported workspaces.
|
||||||
|
/// </summary>
|
||||||
|
public const string WORKSPACES_DIRECTORY = "workspaces";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The archive directory containing all exported temporary chats.
|
||||||
|
/// </summary>
|
||||||
|
public const string TEMPORARY_CHATS_DIRECTORY = "tempChats";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file storing the workspace or chat name.
|
||||||
|
/// </summary>
|
||||||
|
public const string NAME_FILE_NAME = "name";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file storing the serialized chat thread.
|
||||||
|
/// </summary>
|
||||||
|
public const string THREAD_FILE_NAME = "thread.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The separator used inside ZIP archives. ZIP entries always use forward slashes,
|
||||||
|
/// regardless of the operating system.
|
||||||
|
/// </summary>
|
||||||
|
public const char ENTRY_SEPARATOR = '/';
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outcome of importing one single chat.
|
||||||
|
/// </summary>
|
||||||
|
public enum ChatArchiveImportOutcome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The chat was written to the chat storage.
|
||||||
|
/// </summary>
|
||||||
|
IMPORTED,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The chat already existed and was left alone.
|
||||||
|
/// </summary>
|
||||||
|
SKIPPED,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The chat could not be imported.
|
||||||
|
/// </summary>
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes the contents of an archive before anything is imported.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Success">Whether the archive could be read.</param>
|
||||||
|
/// <param name="Manifest">The manifest of the archive.</param>
|
||||||
|
/// <param name="Issue">The reason why the archive could not be read, if it could not.</param>
|
||||||
|
public sealed record ChatArchiveImportPreview(bool Success, ChatArchiveManifest Manifest, string Issue);
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outcome of a chat archive import.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Success">Whether the import ran without an error.</param>
|
||||||
|
/// <param name="ImportedWorkspaces">The number of workspaces which received chats.</param>
|
||||||
|
/// <param name="ImportedChats">The number of imported chats.</param>
|
||||||
|
/// <param name="SkippedChats">The number of chats which were skipped, because they already existed.</param>
|
||||||
|
/// <param name="FailedChats">The number of chats which could not be imported.</param>
|
||||||
|
/// <param name="Issue">The reason why the import failed, if it did.</param>
|
||||||
|
/// <param name="Cancelled">Whether the user stopped the import. Chats imported up to that point are kept.</param>
|
||||||
|
public sealed record ChatArchiveImportResult(
|
||||||
|
bool Success,
|
||||||
|
int ImportedWorkspaces,
|
||||||
|
int ImportedChats,
|
||||||
|
int SkippedChats,
|
||||||
|
int FailedChats,
|
||||||
|
string Issue,
|
||||||
|
bool Cancelled);
|
||||||
519
app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs
Normal file
519
app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using AIStudio.Chat;
|
||||||
|
using AIStudio.Settings;
|
||||||
|
using AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads chat archives and writes their contents back into the chat storage.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChatArchiveImporter
|
||||||
|
{
|
||||||
|
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(ChatArchiveImporter));
|
||||||
|
|
||||||
|
private static readonly int BUFFER_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatArchiveImporter).Namespace, nameof(ChatArchiveImporter));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the entry name with forward slashes. The ZIP format prescribes them, but
|
||||||
|
/// some tools on Windows write backslashes instead. Reading both keeps archives from
|
||||||
|
/// such tools importable.
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeEntryName(string entryName) => entryName.Replace('\\', ChatArchiveFormat.ENTRY_SEPARATOR);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the manifest of an archive without importing anything.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="archivePath">The archive to inspect.</param>
|
||||||
|
/// <param name="token">Cancels reading the manifest.</param>
|
||||||
|
/// <returns>The preview of the archive contents.</returns>
|
||||||
|
public static async Task<ChatArchiveImportPreview> ReadPreviewAsync(string archivePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(archivePath);
|
||||||
|
var manifestEntry = archive.GetEntry(ChatArchiveFormat.MANIFEST_FILE_NAME);
|
||||||
|
if (manifestEntry is null)
|
||||||
|
return new(false, new(), TB("This file is not a chat archive."));
|
||||||
|
|
||||||
|
await using var manifestStream = manifestEntry.Open();
|
||||||
|
var manifest = await JsonSerializer.DeserializeAsync<ChatArchiveManifest>(manifestStream, WorkspaceBehaviour.JSON_OPTIONS, token);
|
||||||
|
if (manifest is null)
|
||||||
|
return new(false, new(), TB("The content list of this archive could not be read."));
|
||||||
|
|
||||||
|
if (manifest.FormatVersion > ChatArchiveFormat.FORMAT_VERSION)
|
||||||
|
return new(false, manifest, TB("This archive was created by a newer version of AI Studio. Please update AI Studio to import it."));
|
||||||
|
|
||||||
|
return new(true, manifest, string.Empty);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
return new(false, new(), TB("This file is not a chat archive."));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogError(exception, "Failed to read the archive '{ArchivePath}'.", archivePath);
|
||||||
|
return new(false, new(), string.Format(TB("Unexpected error: {0}"), exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports all chats of an archive into the chat storage.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="archivePath">The archive to import.</param>
|
||||||
|
/// <param name="collisionBehavior">What to do with chats which already exist.</param>
|
||||||
|
/// <param name="progress">Receives the import progress.</param>
|
||||||
|
/// <param name="token">Cancels the import. Chats imported before the cancellation are kept.</param>
|
||||||
|
/// <returns>The import result.</returns>
|
||||||
|
public static async Task<ChatArchiveImportResult> ImportAsync(string archivePath, ChatArchiveCollisionBehavior collisionBehavior, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
var importedWorkspaces = 0;
|
||||||
|
var importedChats = 0;
|
||||||
|
var skippedChats = 0;
|
||||||
|
var failedChats = 0;
|
||||||
|
|
||||||
|
var preview = await ReadPreviewAsync(archivePath, token);
|
||||||
|
if (!preview.Success)
|
||||||
|
return new(false, 0, 0, 0, 0, preview.Issue, false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = preview.Manifest;
|
||||||
|
var totalChats = manifest.TotalChatCount;
|
||||||
|
var processedChats = 0;
|
||||||
|
|
||||||
|
using var archive = ZipFile.OpenRead(archivePath);
|
||||||
|
var entriesByChat = GroupEntriesByChat(archive);
|
||||||
|
|
||||||
|
// Which workspaces exist already is asked once per archived workspace, so read
|
||||||
|
// the tree once instead of building a full snapshot every time:
|
||||||
|
var tree = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||||
|
var knownWorkspaceIds = tree.Workspaces.Select(workspace => workspace.WorkspaceId).ToHashSet();
|
||||||
|
|
||||||
|
//
|
||||||
|
// A chat counts as existing wherever it is stored, not just in the workspace the
|
||||||
|
// archive names: it may have been moved since the export. Importing it there again
|
||||||
|
// under the same identity would leave two chats with one ID, while the app finds a
|
||||||
|
// chat, and the answer being written for it, by that ID alone:
|
||||||
|
//
|
||||||
|
var existingChatIds = tree.TemporaryChats.Select(chat => chat.ChatId).ToHashSet();
|
||||||
|
foreach (var workspace in tree.Workspaces)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
foreach (var chat in await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspace.WorkspaceId))
|
||||||
|
existingChatIds.Add(chat.ChatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Workspace chats and temporary chats differ only in where they are stored.
|
||||||
|
// Every counter is raised inside the loop: a cancellation leaves the chats
|
||||||
|
// imported so far in the storage, so the numbers have to describe them even
|
||||||
|
// when the loop never reaches its end.
|
||||||
|
//
|
||||||
|
async Task ImportChatsAsync(string entryPrefix, Guid targetWorkspaceId, IReadOnlyList<ChatArchiveManifestChat> chats, bool countsAsWorkspace)
|
||||||
|
{
|
||||||
|
var isFirstImportOfWorkspace = true;
|
||||||
|
foreach (var chat in chats)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
progress?.Report(new(processedChats, totalChats));
|
||||||
|
processedChats++;
|
||||||
|
|
||||||
|
var chatEntryPrefix = $"{entryPrefix}{chat.ChatId}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
var chatEntries = entriesByChat.GetValueOrDefault(chatEntryPrefix, []);
|
||||||
|
|
||||||
|
switch (await ImportChatAsync(chatEntries, chatEntryPrefix, targetWorkspaceId, chat, collisionBehavior, existingChatIds, token))
|
||||||
|
{
|
||||||
|
case ChatArchiveImportOutcome.IMPORTED:
|
||||||
|
importedChats++;
|
||||||
|
if (countsAsWorkspace && isFirstImportOfWorkspace)
|
||||||
|
{
|
||||||
|
importedWorkspaces++;
|
||||||
|
isFirstImportOfWorkspace = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ChatArchiveImportOutcome.SKIPPED:
|
||||||
|
skippedChats++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
failedChats++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var workspace in manifest.Workspaces)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var targetWorkspaceId = await ResolveTargetWorkspaceAsync(workspace, knownWorkspaceIds);
|
||||||
|
if (targetWorkspaceId == Guid.Empty)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("Skipping workspace '{WorkspaceName}' of the archive, because no target workspace could be prepared.", workspace.Name);
|
||||||
|
failedChats += workspace.Chats.Count;
|
||||||
|
processedChats += workspace.Chats.Count;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var workspaceEntryPrefix = $"{ChatArchiveFormat.WORKSPACES_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}{workspace.WorkspaceId}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
await ImportChatsAsync(workspaceEntryPrefix, targetWorkspaceId, workspace.Chats, countsAsWorkspace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryChatsEntryPrefix = $"{ChatArchiveFormat.TEMPORARY_CHATS_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}";
|
||||||
|
await ImportChatsAsync(temporaryChatsEntryPrefix, Guid.Empty, manifest.TemporaryChats, countsAsWorkspace: false);
|
||||||
|
|
||||||
|
progress?.Report(new(processedChats, totalChats));
|
||||||
|
LOG.LogInformation("Imported {ImportedChats} chats from the archive '{ArchivePath}'. Skipped {SkippedChats} existing chats, {FailedChats} chats failed.", importedChats, archivePath, skippedChats, failedChats);
|
||||||
|
|
||||||
|
return new(true, importedWorkspaces, importedChats, skippedChats, failedChats, string.Empty, false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Unlike a cancelled export, which leaves no archive behind, the chats imported
|
||||||
|
// so far stay in the chat storage. So report what happened instead of throwing,
|
||||||
|
// otherwise the user has no idea what is now part of their chats:
|
||||||
|
//
|
||||||
|
LOG.LogInformation("The user cancelled the import of the archive '{ArchivePath}' after {ImportedChats} chats.", archivePath, importedChats);
|
||||||
|
return new(true, importedWorkspaces, importedChats, skippedChats, failedChats, string.Empty, true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogError(exception, "Failed to import the archive '{ArchivePath}'.", archivePath);
|
||||||
|
return new(false, importedWorkspaces, importedChats, skippedChats, failedChats, string.Format(TB("Unexpected error: {0}"), exception.Message), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sorts all archive entries by the chat they belong to. Without this, every chat would
|
||||||
|
/// have to scan all entries of the archive.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<string, List<ZipArchiveEntry>> GroupEntriesByChat(ZipArchive archive)
|
||||||
|
{
|
||||||
|
var entriesByChat = new Dictionary<string, List<ZipArchiveEntry>>(StringComparer.Ordinal);
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
var entryName = NormalizeEntryName(entry.FullName);
|
||||||
|
|
||||||
|
// Skip directory entries:
|
||||||
|
if (entryName.EndsWith(ChatArchiveFormat.ENTRY_SEPARATOR))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var chatEntryPrefix = GetChatEntryPrefix(entryName);
|
||||||
|
if (chatEntryPrefix is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!entriesByChat.TryGetValue(chatEntryPrefix, out var chatEntries))
|
||||||
|
{
|
||||||
|
chatEntries = [];
|
||||||
|
entriesByChat[chatEntryPrefix] = chatEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatEntries.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesByChat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the chat an archive entry belongs to: everything up to and including the
|
||||||
|
/// chat identifier, so <c>workspaces/W/C/thread.json</c> becomes <c>workspaces/W/C/</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The chat prefix, or null when the entry belongs to no chat.</returns>
|
||||||
|
private static string? GetChatEntryPrefix(string entryName)
|
||||||
|
{
|
||||||
|
var expectedSeparators = 0;
|
||||||
|
if (entryName.StartsWith($"{ChatArchiveFormat.WORKSPACES_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}", StringComparison.Ordinal))
|
||||||
|
expectedSeparators = 3;
|
||||||
|
else if (entryName.StartsWith($"{ChatArchiveFormat.TEMPORARY_CHATS_DIRECTORY}{ChatArchiveFormat.ENTRY_SEPARATOR}", StringComparison.Ordinal))
|
||||||
|
expectedSeparators = 2;
|
||||||
|
|
||||||
|
if (expectedSeparators is 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var index = 0;
|
||||||
|
for (var separator = 0; separator < expectedSeparators; separator++)
|
||||||
|
{
|
||||||
|
var next = entryName.IndexOf(ChatArchiveFormat.ENTRY_SEPARATOR, index);
|
||||||
|
if (next < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
index = next + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entryName[..index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the workspace which receives the chats of an archived workspace. Known
|
||||||
|
/// workspaces are reused, unknown ones are created with their original identity.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workspace">The archived workspace.</param>
|
||||||
|
/// <param name="knownWorkspaceIds">The workspaces which exist already. Newly created ones are added.</param>
|
||||||
|
private static async Task<Guid> ResolveTargetWorkspaceAsync(ChatArchiveManifestWorkspace workspace, HashSet<Guid> knownWorkspaceIds)
|
||||||
|
{
|
||||||
|
if (workspace.WorkspaceId == Guid.Empty)
|
||||||
|
return Guid.Empty;
|
||||||
|
|
||||||
|
if (knownWorkspaceIds.Contains(workspace.WorkspaceId))
|
||||||
|
return workspace.WorkspaceId;
|
||||||
|
|
||||||
|
var workspaceName = string.IsNullOrWhiteSpace(workspace.Name) ? TB("Imported workspace") : workspace.Name;
|
||||||
|
await WorkspaceBehaviour.EnsureWorkspace(workspace.WorkspaceId, await CreateUniqueWorkspaceNameAsync(workspaceName));
|
||||||
|
knownWorkspaceIds.Add(workspace.WorkspaceId);
|
||||||
|
|
||||||
|
return workspace.WorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extends the workspace name by a counter until it is no longer taken. Workspace names
|
||||||
|
/// must be unique, otherwise the workspace cannot be renamed later on.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> CreateUniqueWorkspaceNameAsync(string workspaceName)
|
||||||
|
{
|
||||||
|
var candidate = workspaceName;
|
||||||
|
var counter = 1;
|
||||||
|
|
||||||
|
while (await WorkspaceBehaviour.IsWorkspaceNameExistingAsync(candidate))
|
||||||
|
{
|
||||||
|
counter++;
|
||||||
|
candidate = $"{workspaceName} ({counter})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports one chat: extracts its files into a fresh chat directory, turns the
|
||||||
|
/// archive-relative attachment paths back into absolute ones, and stores the chat.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="existingChatIds">The IDs of all chats in the storage. An imported chat is added.</param>
|
||||||
|
private static async Task<ChatArchiveImportOutcome> ImportChatAsync(IReadOnlyList<ZipArchiveEntry> chatEntries, string chatEntryPrefix, Guid targetWorkspaceId, ChatArchiveManifestChat chat, ChatArchiveCollisionBehavior collisionBehavior, HashSet<Guid> existingChatIds, CancellationToken token)
|
||||||
|
{
|
||||||
|
var threadEntry = chatEntries.FirstOrDefault(entry => NormalizeEntryName(entry.FullName).Equals($"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (threadEntry is null)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("The archive contains no chat thread for the chat '{ChatId}'.", chat.ChatId);
|
||||||
|
return ChatArchiveImportOutcome.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// The directory is checked as well: a chat the storage could not list, for example
|
||||||
|
// because its thread is unreadable, still occupies its place on disk.
|
||||||
|
//
|
||||||
|
var targetChatId = chat.ChatId;
|
||||||
|
if (existingChatIds.Contains(targetChatId) || WorkspaceBehaviour.IsChatExisting(new(targetWorkspaceId, targetChatId)))
|
||||||
|
{
|
||||||
|
if (collisionBehavior is ChatArchiveCollisionBehavior.SKIP)
|
||||||
|
return ChatArchiveImportOutcome.SKIPPED;
|
||||||
|
|
||||||
|
targetChatId = Guid.NewGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatDirectory = WorkspaceBehaviour.GetChatDirectory(targetWorkspaceId, targetChatId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(chatDirectory);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Extract everything except the chat thread and the name file. Both are written
|
||||||
|
// by the chat storage itself once the paths inside the thread are corrected:
|
||||||
|
//
|
||||||
|
var extractedPaths = new HashSet<string>(PathTools.COMPARER);
|
||||||
|
foreach (var entry in chatEntries)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var relativePath = NormalizeEntryName(entry.FullName)[chatEntryPrefix.Length..];
|
||||||
|
if (relativePath.Equals(ChatArchiveFormat.THREAD_FILE_NAME, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relativePath.Equals(ChatArchiveFormat.NAME_FILE_NAME, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var destinationPath = ResolveSafeDestination(chatDirectory, relativePath);
|
||||||
|
if (destinationPath is null)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("Ignoring the archive entry '{EntryName}', because it points outside of the chat directory.", entry.FullName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
|
|
||||||
|
await using var entryStream = entry.Open();
|
||||||
|
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
await entryStream.CopyToAsync(fileStream, token);
|
||||||
|
|
||||||
|
extractedPaths.Add(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatThread? thread;
|
||||||
|
await using (var threadStream = threadEntry.Open())
|
||||||
|
thread = await JsonSerializer.DeserializeAsync<ChatThread>(threadStream, WorkspaceBehaviour.JSON_OPTIONS, token);
|
||||||
|
|
||||||
|
if (thread is null)
|
||||||
|
{
|
||||||
|
LOG.LogWarning("The chat thread of the chat '{ChatId}' could not be read.", chat.ChatId);
|
||||||
|
TryDeleteDirectory(chatDirectory);
|
||||||
|
return ChatArchiveImportOutcome.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread = thread with { ChatId = targetChatId };
|
||||||
|
thread.WorkspaceId = targetWorkspaceId;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Turn the archive-relative paths back into absolute ones. Only paths whose files
|
||||||
|
// were actually extracted are rewritten. Everything else was already absolute when
|
||||||
|
// the archive was created and stays as it is:
|
||||||
|
//
|
||||||
|
ChatArchiveAttachmentPaths.Rewrite(thread, path =>
|
||||||
|
{
|
||||||
|
var normalizedPath = path.Replace(Path.DirectorySeparatorChar, ChatArchiveFormat.ENTRY_SEPARATOR);
|
||||||
|
if (!extractedPaths.Contains(normalizedPath))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
return Path.Combine(chatDirectory, normalizedPath.Replace(ChatArchiveFormat.ENTRY_SEPARATOR, Path.DirectorySeparatorChar));
|
||||||
|
});
|
||||||
|
|
||||||
|
CopyTranscriptsStoredElsewhere(thread, chatDirectory);
|
||||||
|
|
||||||
|
//
|
||||||
|
// The storage skips a chat whose lock another operation holds for too long. The
|
||||||
|
// directory prepared above would then hold the extracted files but no chat:
|
||||||
|
//
|
||||||
|
if (!await WorkspaceBehaviour.StoreChatAsync(thread))
|
||||||
|
{
|
||||||
|
LOG.LogWarning("The chat '{ChatId}' could not be stored, because another operation held it for too long.", chat.ChatId);
|
||||||
|
TryDeleteDirectory(chatDirectory);
|
||||||
|
return ChatArchiveImportOutcome.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingChatIds.Add(targetChatId);
|
||||||
|
return ChatArchiveImportOutcome.IMPORTED;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(chatDirectory);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogError(exception, "Failed to import the chat '{ChatId}' into the workspace '{WorkspaceId}'.", chat.ChatId, targetWorkspaceId);
|
||||||
|
TryDeleteDirectory(chatDirectory);
|
||||||
|
return ChatArchiveImportOutcome.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives the imported chat its own copy of every transcript the archive did not carry.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Such a transcript still names the place where it lay when the archive was made. On the
|
||||||
|
/// same computer that is the chat the archive came from, and since a chat deletes the
|
||||||
|
/// transcripts it names, deleting the imported chat would take the transcripts of the
|
||||||
|
/// original along. A copied chat gets its own copies for the same reason, so this uses the
|
||||||
|
/// same code. A transcript which no longer exists keeps its place in the chat and shows up
|
||||||
|
/// as missing, again as it does in a copy.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="thread">The imported chat thread, after its archive-relative paths were resolved.</param>
|
||||||
|
/// <param name="chatDirectory">The directory of the imported chat.</param>
|
||||||
|
private static void CopyTranscriptsStoredElsewhere(ChatThread thread, string chatDirectory)
|
||||||
|
{
|
||||||
|
var transcriptDirectory = Path.Combine(chatDirectory, "attachments", "transcripts");
|
||||||
|
var copiedPaths = new Dictionary<string, ManagedTranscriptAttachment>(PathTools.COMPARER);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Only a transcript in the data directory can belong to another chat, and only there
|
||||||
|
// does deleting a chat delete its transcripts. Any other path is left alone, so that an
|
||||||
|
// archive cannot get files from elsewhere on this computer copied into a chat:
|
||||||
|
//
|
||||||
|
ManagedTranscriptAttachment CopyWhenElsewhere(ManagedTranscriptAttachment transcript) =>
|
||||||
|
IsInsideDirectory(chatDirectory, transcript.FilePath) || !IsInsideDirectory(SettingsManager.DataDirectory!, transcript.FilePath)
|
||||||
|
? transcript
|
||||||
|
: WorkspaceBehaviour.CopyManagedTranscriptAttachment(thread, transcript, transcriptDirectory, copiedPaths);
|
||||||
|
|
||||||
|
foreach (var content in thread.Blocks.Select(block => block.Content).OfType<IContent>())
|
||||||
|
{
|
||||||
|
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||||
|
{
|
||||||
|
if (content.FileAttachments[index] is ManagedTranscriptAttachment transcript)
|
||||||
|
content.FileAttachments[index] = CopyWhenElsewhere(transcript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < thread.PendingMediaTranscripts.Count; index++)
|
||||||
|
thread.PendingMediaTranscripts[index] = CopyWhenElsewhere(thread.PendingMediaTranscripts[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a path lies below the given directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A path which cannot be resolved counts as lying elsewhere. The copy which follows then
|
||||||
|
/// decides what becomes of it, and it keeps such an attachment as it is.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IsInsideDirectory(string directory, string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullDirectory = Path.GetFullPath(directory);
|
||||||
|
var directoryPrefix = fullDirectory.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? fullDirectory
|
||||||
|
: fullDirectory + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
return Path.GetFullPath(path).StartsWith(directoryPrefix, PathTools.COMPARISON);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combines the chat directory with an archive-relative path and rejects paths which
|
||||||
|
/// would escape the chat directory.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ResolveSafeDestination(string chatDirectory, string relativePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullChatDirectory = Path.GetFullPath(chatDirectory);
|
||||||
|
var destinationPath = Path.GetFullPath(Path.Combine(fullChatDirectory, relativePath.Replace(ChatArchiveFormat.ENTRY_SEPARATOR, Path.DirectorySeparatorChar)));
|
||||||
|
|
||||||
|
var directoryPrefix = fullChatDirectory.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? fullChatDirectory
|
||||||
|
: fullChatDirectory + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
return destinationPath.StartsWith(directoryPrefix, PathTools.COMPARISON) ? destinationPath : null;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogWarning(exception, "Could not resolve the archive path '{RelativePath}' inside the chat directory '{ChatDirectory}'.", relativePath, chatDirectory);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string directory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LOG.LogWarning(exception, "Failed to clean up the incomplete chat directory '{ChatDirectory}'.", directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The manifest at the root of a chat archive. It describes the archive contents, so that
|
||||||
|
/// the import can show a preview without extracting anything.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChatArchiveManifest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The format version of this archive.
|
||||||
|
/// </summary>
|
||||||
|
public int FormatVersion { get; init; } = ChatArchiveFormat.FORMAT_VERSION;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The time when this archive was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset ExportedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The AI Studio version which created this archive. Informational only.
|
||||||
|
/// </summary>
|
||||||
|
public string AppVersion { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All workspaces contained in this archive.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatArchiveManifestWorkspace> Workspaces { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All temporary chats contained in this archive.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatArchiveManifestChat> TemporaryChats { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the total number of chats in this archive.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int TotalChatCount => this.Workspaces.Sum(workspace => workspace.Chats.Count) + this.TemporaryChats.Count;
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes one exported chat inside a chat archive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChatArchiveManifestChat
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the chat.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ChatId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the chat at the time of the export.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The time of the last edit at the time of the export.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset LastEditTime { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of app-owned files of this chat which were included in the archive,
|
||||||
|
/// such as transcripts.
|
||||||
|
/// </summary>
|
||||||
|
public int IncludedFiles { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes one exported workspace inside a chat archive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChatArchiveManifestWorkspace
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the workspace.
|
||||||
|
/// </summary>
|
||||||
|
public Guid WorkspaceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the workspace at the time of the export.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All chats of this workspace contained in the archive.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatArchiveManifestChat> Chats { get; init; } = [];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace AIStudio.Tools.ChatArchive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the progress of an export or import operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ProcessedChats">The number of chats processed so far.</param>
|
||||||
|
/// <param name="TotalChats">The total number of chats to process.</param>
|
||||||
|
public readonly record struct ChatArchiveProgress(int ProcessedChats, int TotalChats);
|
||||||
18
app/MindWork AI Studio/Tools/PathTools.cs
Normal file
18
app/MindWork AI Studio/Tools/PathTools.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace AIStudio.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared rules for comparing file system paths.
|
||||||
|
/// </summary>
|
||||||
|
public static class PathTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How to compare file system paths: Windows treats them case-insensitively, Linux and
|
||||||
|
/// macOS do not. There, two files may differ in casing alone.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly StringComparison COMPARISON = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The comparer matching <see cref="COMPARISON"/>, for path-keyed lookups.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly StringComparer COMPARER = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using AIStudio.Tools.ChatArchive;
|
||||||
using AIStudio.Tools.PluginSystem;
|
using AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
// ReSharper disable MemberCanBePrivate.Global
|
// ReSharper disable MemberCanBePrivate.Global
|
||||||
@ -104,6 +105,7 @@ public static class FileTypes
|
|||||||
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
|
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
|
||||||
public static readonly FileTypeFilter SHORTCUT = FileTypeFilter.Leaf(TB("Shortcut"), "lnk");
|
public static readonly FileTypeFilter SHORTCUT = FileTypeFilter.Leaf(TB("Shortcut"), "lnk");
|
||||||
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
|
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
|
||||||
|
public static readonly FileTypeFilter CHAT_ARCHIVE = FileTypeFilter.Leaf(TB("Chat archive"), ChatArchiveFormat.FILE_EXTENSION.TrimStart('.'), "zip");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The file types AI Studio converts using Pandoc.
|
/// The file types AI Studio converts using Pandoc.
|
||||||
|
|||||||
@ -875,7 +875,7 @@ public static class WorkspaceBehaviour
|
|||||||
chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
|
chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
|
internal static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
|
||||||
ChatThread chat,
|
ChatThread chat,
|
||||||
ManagedTranscriptAttachment source,
|
ManagedTranscriptAttachment source,
|
||||||
string targetTranscriptDirectory,
|
string targetTranscriptDirectory,
|
||||||
@ -1130,7 +1130,7 @@ public static class WorkspaceBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns the canonical storage directory for a chat identity.</summary>
|
/// <summary>Returns the canonical storage directory for a chat identity.</summary>
|
||||||
private static string GetChatDirectory(Guid workspaceId, Guid chatId) => workspaceId == Guid.Empty
|
public static string GetChatDirectory(Guid workspaceId, Guid chatId) => workspaceId == Guid.Empty
|
||||||
? Path.Join(SettingsManager.DataDirectory, "tempChats", chatId.ToString())
|
? Path.Join(SettingsManager.DataDirectory, "tempChats", chatId.ToString())
|
||||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
: Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
||||||
|
|
||||||
@ -1280,7 +1280,11 @@ public static class WorkspaceBehaviour
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
|
/// <summary>
|
||||||
|
/// Ensures that a workspace with the given identity exists on disk and in the cache.
|
||||||
|
/// An existing workspace keeps its current name.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
|
||||||
{
|
{
|
||||||
var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString());
|
var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString());
|
||||||
var workspaceNamePath = Path.Join(workspacePath, "name");
|
var workspaceNamePath = Path.Join(workspacePath, "name");
|
||||||
|
|||||||
@ -40,6 +40,9 @@
|
|||||||
- Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key.
|
- Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key.
|
||||||
- Added a setting for the audio quality used when your speech and your audio and video files are transcribed. AI Studio prepares every recording before it goes to your transcription provider, and you now decide how much detail it keeps: a lower quality travels faster, a higher one gives the transcription model more to work with. You find it in the app settings, right below your transcription provider. Thanks, Dominic Neuburg (`donework`), for this contribution.
|
- Added a setting for the audio quality used when your speech and your audio and video files are transcribed. AI Studio prepares every recording before it goes to your transcription provider, and you now decide how much detail it keeps: a lower quality travels faster, a higher one gives the transcription model more to work with. You find it in the app settings, right below your transcription provider. Thanks, Dominic Neuburg (`donework`), for this contribution.
|
||||||
- Added organization-wide management for the audio quality used when transcribing. IT departments can set the quality their organization works with and lock it, or leave it as a default their colleagues are free to change.
|
- Added organization-wide management for the audio quality used when transcribing. IT departments can set the quality their organization works with and lock it, or leave it as a default their colleagues are free to change.
|
||||||
|
- Added data backup and restore for your chats to the settings. You write the chats of the workspaces you choose into a single archive file, which you can keep as a backup or take to another computer, and you import that file again whenever you need your chats back.
|
||||||
|
- Added chat backups that leave your attached documents where they are. When you back up chats in the settings, documents such as PDF or Word files are not copied into the archive. Your chats keep the place where each document is stored, so the documents stay usable wherever that place can be reached.
|
||||||
|
- Added transcripts to your chat backups when you want them. When you back up chats in the settings which hold transcripts of audio or video files, AI Studio asks whether the transcripts go into the archive, because they exist nowhere else.
|
||||||
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
|
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
|
||||||
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
|
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
|
||||||
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
|
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user