mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-16 16:13:48 +00:00
Added backups of all data, restorable on another computer (#1008)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
ab45580473
commit
936043319c
144 files changed
+10417
-424
No files matched your search
@@ -30,6 +30,8 @@ Key structure:
|
||||
- **Naming conventions** - Constants, enum members, and `static readonly` fields use `UPPER_SNAKE_CASE` such as `MY_CONSTANT`.
|
||||
- **Message Bus** - Singleton event bus for cross-component communication inside the .NET app
|
||||
- **Prompt injection reports** - The user hears once per action about what the filter removed, as soon as that action ends. Content which belongs to an action carries its scope from `PromptInjectionGuardService.BeginAction` down to where it is read or filtered; whatever is filtered without a scope is reported right away. Pass the scope as a parameter, never through a field or an `AsyncLocal`: the service serves the whole app, and the message bus delivers to other receivers in the execution context of whoever sends.
|
||||
- **Paths in stored data** - A stored path to a file which AI Studio keeps below the data directory, such as a transcript or a tokenizer copy, is relative to that directory, so that a backup or a copy of the data directory works on another computer, under another user name, or on another OS. Mark such a property with `[JsonConverter(typeof(DataDirectoryRelativePathConverter))]`, for a positional record parameter with `[property: ...]`; in memory, the path stays absolute. Better still, derive the path from IDs and store none, as `VisualBriefingStore` does. Paths to files of the user stay absolute. Chats stored before v26.10.1 are migrated by a compatibility shim, see `documentation/compatibility-shims/2026-10-relative-data-paths.md`.
|
||||
- **New files and folders in the data or configuration directory** - Whoever adds a file or folder directly to the data or configuration directory, or below `plugins/` or `databases/` one which needs a decision of its own, lists it in `DataBackupContents.ENTRIES`: as part of a backup, or with the reason a backup leaves it out. A backup takes along only what is listed as included, and a restore replaces only that. An entry included in a backup also goes into `REPLACEABLE_ELEMENTS` in `runtime/src/data_restore.rs`; otherwise the runtime refuses to restore it. The folder panel on the information page shows the list to users who back up these folders with a tool of their own.
|
||||
- **Encryption** - Initialized before Rust service is marked ready
|
||||
- **Debug environment** - Reads `startup.env` file with IPC credentials
|
||||
- **Production environment** - Runtime launches .NET sidecar with environment variables
|
||||
|
||||
@@ -1159,11 +1159,22 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
""", true);
|
||||
var fileListAnswer = await this.AddAIResponseAsync(time, true);
|
||||
|
||||
// Is this an update of the ERI server? If so, we need to delete the previously generated files:
|
||||
//
|
||||
// Is this an update of the ERI server? If so, we need to delete the previously generated files.
|
||||
// Only those within the current base directory, though: the list names files by their full path,
|
||||
// and it outlives a change of the base directory, as well as a restore on another computer.
|
||||
// There, the same path may belong to files of somebody else:
|
||||
//
|
||||
if (writeToFilesystemSnapshot && previouslyGeneratedFilesSnapshot.Length > 0 && !string.IsNullOrWhiteSpace(fileListAnswer))
|
||||
{
|
||||
foreach (var file in previouslyGeneratedFilesSnapshot)
|
||||
{
|
||||
if (!PathTools.IsInsideDirectory(baseDirectorySnapshot, file))
|
||||
{
|
||||
this.Logger.LogWarning($"The previously created file '{file}' is not inside the base directory '{baseDirectorySnapshot}' and was kept.");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(file))
|
||||
@@ -1212,7 +1223,7 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
// Security check: ensure that the desired file path is inside the base directory.
|
||||
// We cannot trust the beginning of the file path because it would be possible
|
||||
// to escape by using `..` in the file path.
|
||||
if (!desiredFilePath.StartsWith(baseDirectorySnapshot, StringComparison.InvariantCultureIgnoreCase) || desiredFilePath.Contains(".."))
|
||||
if (!PathTools.IsInsideDirectory(baseDirectorySnapshot, desiredFilePath))
|
||||
this.Logger.LogWarning($"The file path '{desiredFilePath}' is may not inside the base directory '{baseDirectorySnapshot}'.");
|
||||
|
||||
else
|
||||
|
||||
@@ -3772,6 +3772,237 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "C
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
|
||||
|
||||
-- Keep the existing chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1274903302"] = "Keep the existing chat"
|
||||
|
||||
-- The backup goes on when you leave this page.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1298413158"] = "The backup goes on when you leave this page."
|
||||
|
||||
-- Unpacking file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1496386567"] = "Unpacking file {0} of {1}"
|
||||
|
||||
-- Created on {0} with AI Studio {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1503285113"] = "Created on {0} with AI Studio {1}"
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1555678613"] = "AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first."
|
||||
|
||||
-- Replace All Data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1670691112"] = "Replace All Data"
|
||||
|
||||
-- Reading the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1731783566"] = "Reading the backup"
|
||||
|
||||
-- Please stay on this page until this is done. Leaving the page stops adding the chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1758163038"] = "Please stay on this page until this is done. Leaving the page stops adding the chats."
|
||||
|
||||
-- Back up
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1823019579"] = "Back up"
|
||||
|
||||
-- When a chat already exists
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2117204104"] = "When a chat already exists"
|
||||
|
||||
-- Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2176422849"] = "Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?"
|
||||
|
||||
-- 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2301695346"] = "1 chat"
|
||||
|
||||
-- Writing file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2416389933"] = "Writing file {0} of {1}"
|
||||
|
||||
-- Temporary chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2532369939"] = "Temporary chats"
|
||||
|
||||
-- Adding the chats was stopped. The chats added until then were kept.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2586532370"] = "Adding the chats was stopped. The chats added until then were kept."
|
||||
|
||||
-- Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T267943793"] = "Restore"
|
||||
|
||||
-- Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2693525709"] = "Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are."
|
||||
|
||||
-- Chats which already existed and stayed as they are: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T29276052"] = "Chats which already existed and stayed as they are: {0}."
|
||||
|
||||
-- The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3006171489"] = "The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew."
|
||||
|
||||
-- Preparing the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3082169282"] = "Preparing the backup"
|
||||
|
||||
-- Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3087579229"] = "Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS."
|
||||
|
||||
-- Choose backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3114985646"] = "Choose backup"
|
||||
|
||||
-- Select a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T311951952"] = "Select a backup"
|
||||
|
||||
-- Add chats only
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3238328507"] = "Add chats only"
|
||||
|
||||
-- Chat {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T355446621"] = "Chat {0} of {1}"
|
||||
|
||||
-- {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3562573794"] = "{0} chats"
|
||||
|
||||
-- Add {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3585479613"] = "Add {0} chats"
|
||||
|
||||
-- Backed up {0} files into: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3630348902"] = "Backed up {0} files into: {1}"
|
||||
|
||||
-- Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3657899737"] = "Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have."
|
||||
|
||||
-- Add it as an additional chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3966117123"] = "Add it as an additional chat"
|
||||
|
||||
-- Chats added: {0}. Workspaces they went into: {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3972560517"] = "Chats added: {0}. Workspaces they went into: {1}."
|
||||
|
||||
-- The backup was stopped. No file was left behind.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T431814261"] = "The backup was stopped. No file was left behind."
|
||||
|
||||
-- Adds the chats of the backup to the chats you have. Nothing else changes.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T457177394"] = "Adds the chats of the backup to the chats you have. Nothing else changes."
|
||||
|
||||
-- Chats which could not be added: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T611768714"] = "Chats which could not be added: {0}."
|
||||
|
||||
-- The restore was stopped. All your data stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T749188343"] = "The restore was stopped. All your data stay as they are."
|
||||
|
||||
-- Back up data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T796613169"] = "Back up data"
|
||||
|
||||
-- Replace all data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T819832488"] = "Replace all data"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T900713019"] = "Cancel"
|
||||
|
||||
-- Copying the indexes of your data sources: {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T907141309"] = "Copying the indexes of your data sources: {0} of {1}"
|
||||
|
||||
-- Add 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T938996955"] = "Add 1 chat"
|
||||
|
||||
-- Folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T104168189"] = "Folder"
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1093747001"] = "Reason"
|
||||
|
||||
-- Configuration folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1191404447"] = "Configuration folder"
|
||||
|
||||
-- Could not open the folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1215994612"] = "Could not open the folder."
|
||||
|
||||
-- Your projects in the Visual Briefing Assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Your projects in the Visual Briefing Assistant."
|
||||
|
||||
-- Your temporary chats, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Your temporary chats, along with their transcripts."
|
||||
|
||||
-- What belongs into a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "What belongs into a backup"
|
||||
|
||||
-- The index of your local data sources: which files AI Studio embedded, and their text.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T170332953"] = "The index of your local data sources: which files AI Studio embedded, and their text."
|
||||
|
||||
-- Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1981169629"] = "Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones."
|
||||
|
||||
-- Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1992121437"] = "Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again."
|
||||
|
||||
-- What a backup can leave out
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2120934651"] = "What a backup can leave out"
|
||||
|
||||
-- Good to know
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Good to know"
|
||||
|
||||
-- Data folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder"
|
||||
|
||||
-- Explanation
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation"
|
||||
|
||||
-- Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2851981238"] = "Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."
|
||||
|
||||
-- The tokenizers you chose for your providers.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2892362083"] = "The tokenizers you chose for your providers."
|
||||
|
||||
-- To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2906063884"] = "To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead."
|
||||
|
||||
-- Your plugins, such as the assistants you built, apart from the folders listed below.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2979110360"] = "Your plugins, such as the assistants you built, apart from the folders listed below."
|
||||
|
||||
-- Open in your file manager
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2986518596"] = "Open in your file manager"
|
||||
|
||||
-- Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3156743605"] = "Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one."
|
||||
|
||||
-- Content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3244793698"] = "Content"
|
||||
|
||||
-- The embeddings of your local data sources.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3301102729"] = "The embeddings of your local data sources."
|
||||
|
||||
-- The folder is not known yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3310368654"] = "The folder is not known yet."
|
||||
|
||||
-- Could not open the folder: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3380661810"] = "Could not open the folder: {0}"
|
||||
|
||||
-- Unknown error
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3461425987"] = "Unknown error"
|
||||
|
||||
-- Plugins of your organization. Your organization brings them back.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T346886000"] = "Plugins of your organization. Your organization brings them back."
|
||||
|
||||
-- Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3655837348"] = "Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew."
|
||||
|
||||
-- Files which AI Studio needs only for a moment and deletes again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T366353671"] = "Files which AI Studio needs only for a moment and deletes again."
|
||||
|
||||
-- Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3877928356"] = "Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."
|
||||
|
||||
-- Path
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3949388886"] = "Path"
|
||||
|
||||
-- Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T4093107108"] = "Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more."
|
||||
|
||||
-- Your chats in workspaces, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T509585668"] = "Your chats in workspaces, along with their transcripts."
|
||||
|
||||
-- Log files. They help when something goes wrong, but they hold none of your data.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T53143530"] = "Log files. They help when something goes wrong, but they hold none of your data."
|
||||
|
||||
-- AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T69622543"] = "AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager."
|
||||
|
||||
-- Your settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T790727583"] = "Your settings."
|
||||
|
||||
-- The following is meant for experts who back up their computer with a tool of their own.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "The following is meant for experts who back up their computer with a tool of their own."
|
||||
|
||||
-- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along."
|
||||
|
||||
-- Yes, please send my data to the external embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Yes, please send my data to the external embedding provider"
|
||||
|
||||
@@ -3808,6 +4039,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T6617545
|
||||
-- All files in this folder and its subfolders
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T916879200"] = "All files in this folder and its subfolders"
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1006756723"] = "AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."
|
||||
|
||||
-- You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task."
|
||||
|
||||
@@ -6085,6 +6319,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No"
|
||||
-- Yes
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T3013883440"] = "Yes"
|
||||
|
||||
-- Restart now
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T1409292924"] = "Restart now"
|
||||
|
||||
-- Discard the restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T3017406920"] = "Discard the restore"
|
||||
|
||||
-- Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T495853773"] = "Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now."
|
||||
|
||||
-- AI Studio could not restart by itself. Please quit AI Studio and start it again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T573887863"] = "AI Studio could not restart by itself. Please quit AI Studio and start it again."
|
||||
|
||||
-- Restoring your backup failed. AI Studio put all your data back the way they were before.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1057857006"] = "Restoring your backup failed. AI Studio put all your data back the way they were before."
|
||||
|
||||
-- Your backup was not restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1193456690"] = "Your backup was not restored"
|
||||
|
||||
-- Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T123391329"] = "Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder."
|
||||
|
||||
-- The batch processing uses '{0}', which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1314976065"] = "The batch processing uses '{0}', which does not exist on this computer."
|
||||
|
||||
-- The backup is from {0} and was made with AI Studio {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1363238678"] = "The backup is from {0} and was made with AI Studio {1}."
|
||||
|
||||
-- A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1633897333"] = "A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:"
|
||||
|
||||
-- Enter the API key of the provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1645175373"] = "Enter the API key of the provider '{0}' again, if it needs one."
|
||||
|
||||
-- Restoring your backup is not finished
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2415452768"] = "Restoring your backup is not finished"
|
||||
|
||||
-- Enter the API key of the embedding provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2492749778"] = "Enter the API key of the embedding provider '{0}' again, if it needs one."
|
||||
|
||||
-- Enter the API key of the transcription provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2568556297"] = "Enter the API key of the transcription provider '{0}' again, if it needs one."
|
||||
|
||||
-- Nothing else to do: every provider and every data source has what it needs on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2671436044"] = "Nothing else to do: every provider and every data source has what it needs on this computer."
|
||||
|
||||
-- The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T270687216"] = "The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew."
|
||||
|
||||
-- The file with your own root certificates, '{0}', does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2844911500"] = "The file with your own root certificates, '{0}', does not exist on this computer."
|
||||
|
||||
-- AI Studio could not start restoring your backup, so all your data stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3044053464"] = "AI Studio could not start restoring your backup, so all your data stayed as they were."
|
||||
|
||||
-- Enter the password of the mailbox '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3083231765"] = "Enter the password of the mailbox '{0}' again."
|
||||
|
||||
-- Enter the password or token of the data source '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3430781520"] = "Enter the password or token of the data source '{0}' again."
|
||||
|
||||
-- Technical details: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3436390766"] = "Technical details: {0}"
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3448155331"] = "Close"
|
||||
|
||||
-- Enter the setting '{1}' of the tool '{0}' again, if you used it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3658964195"] = "Enter the setting '{1}' of the tool '{0}' again, if you used it."
|
||||
|
||||
-- AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3896631626"] = "AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were."
|
||||
|
||||
-- Your backup was restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Your backup was restored"
|
||||
|
||||
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "The ERI server '{0}' writes into a folder which does not exist on this computer."
|
||||
|
||||
-- How many matches do you want at most per query?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "How many matches do you want at most per query?"
|
||||
|
||||
@@ -9385,6 +9697,9 @@ UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2800137365"] = "An update to ver
|
||||
-- Please wait for the update to complete...
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2864211629"] = "Please wait for the update to complete..."
|
||||
|
||||
-- Restore of a Backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2912921801"] = "Restore of a Backup"
|
||||
|
||||
-- Supporters
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2929332068"] = "Supporters"
|
||||
|
||||
@@ -10189,6 +10504,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3294830584"] = "Discover MindWor
|
||||
-- External HTTPS custom root certificates
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "External HTTPS custom root certificates"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3317700277"] = "Data Backup & Restore"
|
||||
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "User-language provided by the OS"
|
||||
|
||||
@@ -10345,6 +10663,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T551035563"] = "Open Log Viewer"
|
||||
-- This is a library providing the foundations for asynchronous programming in Rust. It includes key trait definitions like Stream, as well as utilities like join!, select!, and various futures combinator methods which enable expressive asynchronous control flow.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library providing the foundations for asynchronous programming in Rust. It includes key trait definitions like Stream, as well as utilities like join!, select!, and various futures combinator methods which enable expressive asynchronous control flow."
|
||||
|
||||
-- Your Data Folders
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T570573187"] = "Your Data Folders"
|
||||
|
||||
-- Used .NET SDK
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK"
|
||||
|
||||
@@ -10528,15 +10849,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "The plugin could not
|
||||
-- Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T3317700277"] = "Data Backup & Restore"
|
||||
|
||||
-- Thank you very much, Kerstin, for creating the Wiki, for advising us on ERI with real use cases, and for your work on version 2 of the ERI protocol.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1367356224"] = "Thank you very much, Kerstin, for creating the Wiki, for advising us on ERI with real use cases, and for your work on version 2 of the ERI protocol."
|
||||
|
||||
-- Thank you for being the first to contribute a one-time donation.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1470916504"] = "Thank you for being the first to contribute a one-time donation."
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1701321325"] = "Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication."
|
||||
|
||||
-- Thank you, Peer, for your courage in being the second person to support the project financially.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1714878838"] = "Thank you, Peer, for your courage in being the second person to support the project financially."
|
||||
|
||||
@@ -10624,6 +10945,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T686206269"] = "Thanks for your bu
|
||||
-- Business Contributors
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T838479287"] = "Business Contributors"
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T882777973"] = "Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication."
|
||||
|
||||
-- Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T916186976"] = "Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI."
|
||||
|
||||
@@ -11266,6 +11590,15 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "None"
|
||||
-- Username & Password
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- Imported workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Imported workspace"
|
||||
|
||||
-- Spanish (Spain)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
||||
|
||||
@@ -11401,6 +11734,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = "
|
||||
-- Trust all LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3812849494"] = "This backup was made by a newer version of AI Studio. Please update AI Studio first."
|
||||
|
||||
-- The content list of this backup could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3954689017"] = "The content list of this backup could not be read."
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- The backup is damaged: the file '{0}' does not match its content list.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3493230602"] = "The backup is damaged: the file '{0}' does not match its content list."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3812849494"] = "This backup was made by a newer version of AI Studio. Please update AI Studio first."
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1093747001"] = "Reason"
|
||||
|
||||
@@ -11551,6 +11902,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T858000918"] = "Thes
|
||||
-- Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T874850580"] = "Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while."
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T1006756723"] = "AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T3448155331"] = "Close"
|
||||
|
||||
-- Repair Data Source
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T4175865785"] = "Repair Data Source"
|
||||
|
||||
@@ -12481,6 +12838,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T377
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
|
||||
-- AI Studio backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1046878067"] = "AI Studio backup"
|
||||
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
@@ -12742,9 +13102,45 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T5
|
||||
-- Workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T658612054"] = "Workspace"
|
||||
|
||||
-- A backup is running already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1319749333"] = "A backup is running already."
|
||||
|
||||
-- The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1869616367"] = "The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive."
|
||||
|
||||
-- The backup failed: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1915001713"] = "The backup failed: {0}"
|
||||
|
||||
-- Your organization does not allow backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T2011318013"] = "Your organization does not allow backups."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please start the backup once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T4161531565"] = "AI Studio is still writing an answer or a transcript. Please start the backup once it is done."
|
||||
|
||||
-- Your organization does not allow restoring backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1032491606"] = "Your organization does not allow restoring backups."
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1555678613"] = "AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1847516630"] = "AI Studio is still writing an answer or a transcript. Please restore once it is done."
|
||||
|
||||
-- The restore could not be prepared: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T2653345012"] = "The restore could not be prepared: {0}"
|
||||
|
||||
-- A restore is being prepared already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T4157700208"] = "A restore is being prepared already."
|
||||
|
||||
-- A backup is running. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T553866901"] = "A backup is running. Please restore once it is done."
|
||||
|
||||
-- The local RAG index database is not available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T1738200026"] = "The local RAG index database is not available."
|
||||
|
||||
-- The index of the data source '{0}' is missing. The data source stays out of your chats until its index was built anew. Use the repair action to start that.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T204783307"] = "The index of the data source '{0}' is missing. The data source stays out of your chats until its index was built anew. Use the repair action to start that."
|
||||
|
||||
-- The selected embedding provider is not allowed to index this data source. The data source asks for the confidence level '{0}', while the embedding provider has '{1}'.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T2186533187"] = "The selected embedding provider is not allowed to index this data source. The data source asks for the confidence level '{0}', while the embedding provider has '{1}'."
|
||||
|
||||
@@ -12850,6 +13246,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360
|
||||
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync."
|
||||
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."
|
||||
|
||||
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
|
||||
|
||||
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."
|
||||
|
||||
@@ -12859,15 +13261,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T242460
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it."
|
||||
|
||||
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source."
|
||||
|
||||
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "The file '{0}' could not be read. Please check whether you are allowed to read it."
|
||||
|
||||
-- The file '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T451561215"] = "The file '{0}' does not exist."
|
||||
|
||||
-- The folder '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T871336081"] = "The folder '{0}' does not exist."
|
||||
|
||||
-- The local index '{0}' could not be created again. Please restart AI Studio and try once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::INDEXEDRUNCONTEXT::T1394295123"] = "The local index '{0}' could not be created again. Please restart AI Studio and try once more."
|
||||
|
||||
|
||||
@@ -555,6 +555,53 @@ public sealed record ChatThread
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every attachment of the chat, in its messages as well as in its draft, with what the given function makes of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one place which knows where a chat keeps attachments. Moving a chat, copying it, and
|
||||
/// writing it into a backup or reading it back from one all go through here, so none of them
|
||||
/// forgets the draft or a kind of message.
|
||||
/// </remarks>
|
||||
/// <param name="replace">Gives the attachment to keep in place of the given one, or the given one itself when nothing changes.</param>
|
||||
public void ReplaceAttachments(Func<FileAttachment, FileAttachment> replace)
|
||||
{
|
||||
foreach (var content in this.Blocks.Select(block => block.Content).OfType<IContent>())
|
||||
{
|
||||
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||
content.FileAttachments[index] = replace(content.FileAttachments[index]);
|
||||
}
|
||||
|
||||
this.Draft.ReplaceAttachments(replace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every file path the chat stores with what the given function makes of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Covers the attachments, cf. ReplaceAttachments, and the images of the messages which are held
|
||||
/// by a local path. An attachment whose path the function returns unchanged stays as it is, and
|
||||
/// so does one without a path.
|
||||
/// </remarks>
|
||||
/// <param name="rewrite">Maps a path to its replacement, or returns it unchanged.</param>
|
||||
public void RewriteFilePaths(Func<string, string> rewrite)
|
||||
{
|
||||
this.ReplaceAttachments(attachment =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.FilePath))
|
||||
return attachment;
|
||||
|
||||
var rewrittenPath = rewrite(attachment.FilePath);
|
||||
return rewrittenPath == attachment.FilePath ? attachment : attachment.WithFilePath(rewrittenPath);
|
||||
});
|
||||
|
||||
foreach (var image in this.Blocks.Select(block => block.Content).OfType<ContentImage>())
|
||||
{
|
||||
if (image.SourceType is ContentImageSource.LOCAL_PATH && !string.IsNullOrWhiteSpace(image.Source))
|
||||
image.Source = rewrite(image.Source);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds what a provider reported for this conversation, as long as the report still describes it.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,13 +9,13 @@ namespace AIStudio.Chat;
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of the file attachment.</param>
|
||||
/// <param name="FileName">The name of the file, including extension.</param>
|
||||
/// <param name="FilePath">The full path to the file, including the filename and extension.</param>
|
||||
/// <param name="FilePath">The full path to the file, including the filename and extension. A file below the data directory, such as a transcript, is stored with a path relative to it.</param>
|
||||
/// <param name="FileSizeBytes">The size of the file in bytes.</param>
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(FileAttachment), typeDiscriminator: "file")]
|
||||
[JsonDerivedType(typeof(FileAttachmentImage), typeDiscriminator: "image")]
|
||||
[JsonDerivedType(typeof(ManagedTranscriptAttachment), typeDiscriminator: "managed_transcript")]
|
||||
public record FileAttachment(FileAttachmentType Type, string FileName, string FilePath, long FileSizeBytes)
|
||||
public record FileAttachment(FileAttachmentType Type, string FileName, [property: JsonConverter(typeof(DataDirectoryRelativePathConverter))] string FilePath, long FileSizeBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the file type is forbidden and should not be attached.
|
||||
@@ -63,6 +63,17 @@ public record FileAttachment(FileAttachmentType Type, string FileName, string Fi
|
||||
/// </summary>
|
||||
public virtual FileAttachment Normalize() => FromPath(this.FilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Makes the same attachment for its file at another place, e.g. after the chat holding the file moved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately no with expression: that one keeps whatever was derived from the old path when
|
||||
/// the attachment was made, such as FilePathAsUrl, and the source of an image.
|
||||
/// </remarks>
|
||||
/// <param name="filePath">The path of the file now.</param>
|
||||
/// <returns>An attachment of the same kind, with the new path.</returns>
|
||||
public virtual FileAttachment WithFilePath(string filePath) => new(this.Type, this.FileName, filePath, this.FileSizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a FileAttachment from a file path by automatically determining the type,
|
||||
/// extracting the filename, and reading the file size.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
public record FileAttachmentImage(string FileName, string FilePath, long FileSizeBytes) : FileAttachment(FileAttachmentType.IMAGE, FileName, FilePath, FileSizeBytes), IImageSource
|
||||
@@ -13,5 +15,18 @@ public record FileAttachmentImage(string FileName, string FilePath, long FileSiz
|
||||
/// <summary>
|
||||
/// The image source.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An attached image is a file, so its source is the path of that file. It is not stored a second
|
||||
/// time: the stored file path is relative when the image lies below the data directory, and the
|
||||
/// source has to follow it when the chat is read again, on whatever computer that happens.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public string Source { get; set; } = FilePath;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override FileAttachment WithFilePath(string filePath) => new FileAttachmentImage(this.FileName, filePath, this.FileSizeBytes)
|
||||
{
|
||||
SourceType = this.SourceType,
|
||||
Source = this.SourceType is ContentImageSource.LOCAL_PATH ? filePath : this.Source,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,17 @@ namespace AIStudio.Chat;
|
||||
public sealed record ManagedTranscriptAttachment(string FileName, string FilePath, long FileSizeBytes, string OriginalFileName, bool IsStaged)
|
||||
: FileAttachment(FileAttachmentType.DOCUMENT, FileName, FilePath, FileSizeBytes)
|
||||
{
|
||||
/// <summary>The folder within a chat directory which holds the attachments AI Studio made for the chat.</summary>
|
||||
private const string ATTACHMENTS_FOLDER = "attachments";
|
||||
|
||||
/// <summary>The folder within the attachments folder which holds the transcripts.</summary>
|
||||
private const string TRANSCRIPTS_FOLDER = "transcripts";
|
||||
|
||||
/// <summary>Gets the directory which holds the transcripts of a chat.</summary>
|
||||
/// <param name="chatDirectory">The directory of the chat.</param>
|
||||
/// <returns>The transcript directory, which may not exist yet.</returns>
|
||||
public static string GetTranscriptDirectory(string chatDirectory) => Path.Combine(chatDirectory, ATTACHMENTS_FOLDER, TRANSCRIPTS_FOLDER);
|
||||
|
||||
/// <summary>Refreshes the path-derived name and current file size.</summary>
|
||||
public override FileAttachment Normalize()
|
||||
{
|
||||
@@ -22,6 +33,9 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
|
||||
return this with { FileName = Path.GetFileName(this.FilePath), FileSizeBytes = size };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override FileAttachment WithFilePath(string filePath) => new ManagedTranscriptAttachment(this.FileName, filePath, this.FileSizeBytes, this.OriginalFileName, this.IsStaged);
|
||||
|
||||
/// <summary>Creates a transcript in an operation-specific staging directory.</summary>
|
||||
/// <param name="originalPath">Original media path.</param>
|
||||
/// <param name="transcript">Provider transcript.</param>
|
||||
@@ -71,7 +85,7 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
|
||||
var fullDataRoot = Path.GetFullPath(SettingsManager.DataDirectory!);
|
||||
var relative = Path.GetRelativePath(fullDataRoot, fullFilePath);
|
||||
|
||||
if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathComparison))
|
||||
if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathTools.COMPARISON))
|
||||
return false;
|
||||
|
||||
if (fileInfo.LinkTarget is not null || HasLinkedDirectory(fileInfo.Directory, fullDataRoot))
|
||||
@@ -82,16 +96,12 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
|
||||
&& Guid.TryParseExact(segments[1], "N", out _)
|
||||
&& !string.IsNullOrWhiteSpace(segments[2]);
|
||||
|
||||
var isTemporaryChatTranscript = segments is ["tempChats", _, _, _, _]
|
||||
var isTemporaryChatTranscript = segments is ["tempChats", _, ATTACHMENTS_FOLDER, TRANSCRIPTS_FOLDER, _]
|
||||
&& Guid.TryParse(segments[1], out _);
|
||||
|
||||
var isWorkspaceChatTranscript = segments is ["workspaces", _, _, ATTACHMENTS_FOLDER, TRANSCRIPTS_FOLDER, _]
|
||||
&& Guid.TryParse(segments[1], out _)
|
||||
&& segments[2] == "attachments"
|
||||
&& segments[3] == "transcripts";
|
||||
|
||||
var isWorkspaceChatTranscript = segments is ["workspaces", _, _, _, _, _]
|
||||
&& Guid.TryParse(segments[1], out _)
|
||||
&& Guid.TryParse(segments[2], out _)
|
||||
&& segments[3] == "attachments"
|
||||
&& segments[4] == "transcripts";
|
||||
&& Guid.TryParse(segments[2], out _);
|
||||
|
||||
if (!isStaging && !isTemporaryChatTranscript && !isWorkspaceChatTranscript)
|
||||
return false;
|
||||
@@ -107,7 +117,7 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
|
||||
/// <summary>Rejects paths traversing any symbolic-link or junction directory below the data root.</summary>
|
||||
private static bool HasLinkedDirectory(DirectoryInfo? directory, string fullDataRoot)
|
||||
{
|
||||
while (directory is not null && !string.Equals(Path.GetFullPath(directory.FullName), fullDataRoot, PathComparison))
|
||||
while (directory is not null && !string.Equals(Path.GetFullPath(directory.FullName), fullDataRoot, PathTools.COMPARISON))
|
||||
{
|
||||
if (directory.LinkTarget is not null)
|
||||
return true;
|
||||
@@ -163,7 +173,4 @@ public sealed record ManagedTranscriptAttachment(string FileName, string FilePat
|
||||
|
||||
await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
/// <summary>Gets the platform path comparison used for canonical containment checks.</summary>
|
||||
private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
@using AIStudio.Tools.ChatArchive
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudTabs Elevation="0" Rounded="@true" ApplyEffectsToContainer="@true" Outlined="@true" PanelClass="pa-4">
|
||||
<MudTabPanel Text="@T("Back up")" Icon="@Icons.Material.Filled.Archive" Disabled="@(this.isBusy || this.DataRestoreService.IsPreparing)">
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataBackupService.IsRunning)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.BackupProgressPercentage" Indeterminate="@(this.DataBackupService.Progress.Total is 0)" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.BackupProgressText
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.DataBackupService.Cancel">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption">
|
||||
@T("The backup goes on when you leave this page.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<LockableButton Text="@T("Back up data")" Icon="@Icons.Material.Filled.Save" OnClickAsync="@this.StartBackupAsync" Disabled="@(() => this.isBusy)" IsLocked="@(() => !this.AllowBackup)" NoMargin="@true"/>
|
||||
|
||||
@if (this.DataBackupService.LastResult is { } backupResult)
|
||||
{
|
||||
@if (backupResult.Success)
|
||||
{
|
||||
<MudAlert Severity="@(backupResult.IncludesDataSourceIndexes ? Severity.Success : Severity.Warning)" Dense="@true" Class="mt-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
|
||||
</MudText>
|
||||
@if (!backupResult.IncludesDataSourceIndexes)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew.")
|
||||
</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else if (backupResult.Cancelled)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="@true" Class="mt-3">
|
||||
@T("The backup was stopped. No file was left behind.")
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
|
||||
@backupResult.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@T("Restore")" Icon="@Icons.Material.Filled.SettingsBackupRestore" Disabled="@(this.isBusy || this.DataBackupService.IsRunning)">
|
||||
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataRestoreService.IsRestoreUnfinished)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
|
||||
@T("AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.")
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2" Class="mb-3">
|
||||
<LockableButton Text="@T("Choose backup")" Icon="@Icons.Material.Filled.FolderOpen" OnClickAsync="@this.ChooseArchiveAsync" Disabled="@(() => this.isBusy || this.DataRestoreService.IsPreparing)" IsLocked="@(() => !this.AllowRestore)" NoMargin="@true"/>
|
||||
@if (!string.IsNullOrWhiteSpace(this.selectedArchivePath))
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.selectedArchivePath
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@if (this.preview is { } backupPreview)
|
||||
{
|
||||
@if (!backupPreview.Success)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mb-3">
|
||||
@backupPreview.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Outlined="@true" Class="pa-3 mb-4">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@string.Format(T("Created on {0} with AI Studio {1}"), CommonTools.FormatTimestampToGeneral(backupPreview.Manifest.CreatedAt.ToLocalTime().DateTime, this.currentCultureInfo), backupPreview.Manifest.AppVersion)
|
||||
</MudText>
|
||||
|
||||
@foreach (var workspace in backupPreview.Manifest.Workspaces)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@($"{workspace.Name} ({this.ChatCount(workspace.Chats.Count)})")
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (backupPreview.Manifest.TemporaryChats.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@($"{T("Temporary chats")} ({this.ChatCount(backupPreview.Manifest.TemporaryChats.Count)})")
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudText Typo="Typo.h6">
|
||||
@T("Replace all data")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are.")
|
||||
</MudText>
|
||||
|
||||
@if (this.DataRestoreService.IsPreparing)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.RestoreProgressPercentage" Indeterminate="@(this.DataRestoreService.Progress.Total is 0)" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.RestoreProgressText
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.DataRestoreService.Cancel">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.RestorePage" OnClick="@this.StartRestoreAsync" Disabled="@(this.isBusy || !this.CanRestore)">
|
||||
@T("Replace all data")
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
@if (this.DataRestoreService.LastResult is { Success: false } restoreResult)
|
||||
{
|
||||
<MudAlert Severity="@(restoreResult.Cancelled ? Severity.Info : Severity.Error)" Dense="@true" Class="mt-3">
|
||||
@(restoreResult.Cancelled ? T("The restore was stopped. All your data stay as they are.") : restoreResult.Issue)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mt-6">
|
||||
@T("Add chats only")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Adds the chats of the backup to the chats you have. Nothing else changes.")
|
||||
</MudText>
|
||||
|
||||
<MudSelect T="ChatArchiveCollisionBehavior" @bind-Value="@this.collisionBehavior" Label="@T("When a chat already exists")" Variant="Variant.Outlined" Margin="Margin.Dense" Disabled="@this.isBusy" Class="mb-3">
|
||||
<MudSelectItem T="ChatArchiveCollisionBehavior" Value="@ChatArchiveCollisionBehavior.SKIP">
|
||||
@T("Keep the existing chat")
|
||||
</MudSelectItem>
|
||||
<MudSelectItem T="ChatArchiveCollisionBehavior" Value="@ChatArchiveCollisionBehavior.IMPORT_AS_COPY">
|
||||
@T("Add it as an additional chat")
|
||||
</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (this.isImporting)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Value="@this.ImportProgressPercentage" Class="mb-2"/>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chat {0} of {1}"), this.importProgress.ProcessedChats + 1, this.importProgress.TotalChats)
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Cancel" OnClick="@this.CancelImport">
|
||||
@T("Cancel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption">
|
||||
@T("Please stay on this page until this is done. Leaving the page stops adding the chats.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" OnClick="@this.StartImportAsync" Disabled="@(this.isBusy || !this.CanImport)">
|
||||
@this.AddChatsText(backupPreview.Manifest.TotalChatCount)
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (this.importResult is { } importOutcome)
|
||||
{
|
||||
@if (importOutcome.Success)
|
||||
{
|
||||
<MudAlert Severity="@(importOutcome.Cancelled ? Severity.Info : Severity.Success)" Dense="@true" Class="mt-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats added: {0}. Workspaces they went into: {1}."), importOutcome.ImportedChats, importOutcome.ImportedWorkspaces)
|
||||
</MudText>
|
||||
@if (importOutcome.Cancelled)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("Adding the chats was stopped. The chats added until then were kept.")
|
||||
</MudText>
|
||||
}
|
||||
@if (importOutcome.SkippedChats > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats which already existed and stayed as they are: {0}."), importOutcome.SkippedChats)
|
||||
</MudText>
|
||||
}
|
||||
@if (importOutcome.FailedChats > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Chats which could not be added: {0}."), importOutcome.FailedChats)
|
||||
</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="@true" Class="mt-3">
|
||||
@importOutcome.Issue
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.ChatArchive;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Backs up all data into one file, and restores such a backup: as a whole, or only its chats.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It appears on the information page and in the settings. A backup and the preparation of a restore
|
||||
/// run in their services, so leaving the page does not stop them; this component only shows them.
|
||||
/// Adding chats runs here, and leaving the page stops it.
|
||||
/// </remarks>
|
||||
public partial class DataBackupPanel : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The shortest time between two progress renders. Without this, a large backup would re-render
|
||||
/// the whole page once per file or chat.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PROGRESS_RENDER_INTERVAL = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
[Inject]
|
||||
private ILogger<DataBackupPanel> Logger { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataBackupService DataBackupService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataRestoreService DataRestoreService { get; init; } = null!;
|
||||
|
||||
private ChatArchiveCollisionBehavior collisionBehavior = ChatArchiveCollisionBehavior.SKIP;
|
||||
private CancellationTokenSource? importCancellation;
|
||||
private ChatArchiveProgress importProgress;
|
||||
private ChatArchiveImportPreview? preview;
|
||||
private ChatArchiveImportResult? importResult;
|
||||
private DateTimeOffset lastImportRender;
|
||||
private DateTimeOffset lastServiceRender;
|
||||
private string selectedArchivePath = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The culture of the active language plugin, which dates are shown in.
|
||||
/// </summary>
|
||||
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
|
||||
|
||||
/// <summary>
|
||||
/// Blocks any further action, starting with the click that opens a file dialog. Without this, a
|
||||
/// second click during the open dialog would start a second run.
|
||||
/// </summary>
|
||||
private bool isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Whether chats are being added, which shows their progress.
|
||||
/// </summary>
|
||||
private bool isImporting;
|
||||
|
||||
private bool AllowBackup => this.SettingsManager.ConfigurationData.App.AllowUserToBackUpData;
|
||||
|
||||
private bool AllowRestore => this.SettingsManager.ConfigurationData.App.AllowUserToRestoreData;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chosen backup can replace all data now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The permission is asked here again and not only when choosing the backup: a configuration
|
||||
/// plugin may take it away while a chosen backup waits.
|
||||
/// </remarks>
|
||||
private bool CanRestore => this.preview is { Success: true } && this.AllowRestore && !this.DataRestoreService.IsRestoreUnfinished;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the chats of the chosen backup can be added now.
|
||||
/// </summary>
|
||||
private bool CanImport => this.preview is { Success: true, Manifest.TotalChatCount: > 0 } && this.AllowRestore && !this.DataRestoreService.IsPreparing;
|
||||
|
||||
private double ImportProgressPercentage => this.importProgress.TotalChats is 0 ? 0d : this.importProgress.ProcessedChats * 100d / this.importProgress.TotalChats;
|
||||
|
||||
private double BackupProgressPercentage => this.DataBackupService.Progress.Total is 0 ? 0d : this.DataBackupService.Progress.Processed * 100d / this.DataBackupService.Progress.Total;
|
||||
|
||||
private double RestoreProgressPercentage => this.DataRestoreService.Progress.Total is 0 ? 0d : this.DataRestoreService.Progress.Processed * 100d / this.DataRestoreService.Progress.Total;
|
||||
|
||||
private string BackupProgressText => this.DataBackupService.Progress switch
|
||||
{
|
||||
{ Phase: DataBackupPhase.COPYING_INDEXES } current => string.Format(T("Copying the indexes of your data sources: {0} of {1}"), current.Processed + 1, current.Total),
|
||||
{ Phase: DataBackupPhase.WRITING, Total: > 0 } current => string.Format(T("Writing file {0} of {1}"), current.Processed, current.Total),
|
||||
_ => T("Preparing the backup"),
|
||||
};
|
||||
|
||||
private string RestoreProgressText => this.DataRestoreService.Progress is { Total: > 0 } current
|
||||
? string.Format(T("Unpacking file {0} of {1}"), current.Processed, current.Total)
|
||||
: T("Reading the backup");
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.DataBackupService.StateChanged += this.OnServiceStateChanged;
|
||||
this.DataRestoreService.StateChanged += this.OnServiceStateChanged;
|
||||
|
||||
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
this.currentCultureInfo = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.DataBackupService.StateChanged -= this.OnServiceStateChanged;
|
||||
this.DataRestoreService.StateChanged -= this.OnServiceStateChanged;
|
||||
this.importCancellation?.Cancel();
|
||||
this.DisposeImportCancellation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// The number of chats, in the singular for one of them.
|
||||
/// </summary>
|
||||
private string ChatCount(int count) => count is 1 ? T("1 chat") : string.Format(T("{0} chats"), count);
|
||||
|
||||
private string AddChatsText(int count) => count is 1 ? T("Add 1 chat") : string.Format(T("Add {0} chats"), count);
|
||||
|
||||
/// <summary>
|
||||
/// Shows what a backup or the preparation of a restore does, which runs in its service, not here.
|
||||
/// </summary>
|
||||
private void OnServiceStateChanged()
|
||||
{
|
||||
// Render at most every PROGRESS_RENDER_INTERVAL, but never skip the end of a run:
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isRunning = this.DataBackupService.IsRunning || this.DataRestoreService.IsPreparing;
|
||||
if (isRunning && now - this.lastServiceRender < PROGRESS_RENDER_INTERVAL)
|
||||
return;
|
||||
|
||||
this.lastServiceRender = now;
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task StartBackupAsync()
|
||||
{
|
||||
if (this.isBusy || this.DataBackupService.IsRunning || !this.AllowBackup)
|
||||
return;
|
||||
|
||||
string archivePath;
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var suggestedFileName = $"ai-studio-backup-{DateTime.Now:yyyy-MM-dd}{DataBackupFormat.FILE_EXTENSION}";
|
||||
var saveResponse = await this.RustService.SaveFile(T("Back up data"), [FileTypes.BACKUP_ARCHIVE], suggestedFileName);
|
||||
if (saveResponse.UserCancelled)
|
||||
return;
|
||||
|
||||
archivePath = saveResponse.SaveFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
//
|
||||
// The service runs the backup and reports every step through StateChanged, so leaving the
|
||||
// page does not stop it:
|
||||
//
|
||||
await this.DataBackupService.CreateBackupAsync(archivePath);
|
||||
}
|
||||
|
||||
private async Task ChooseArchiveAsync()
|
||||
{
|
||||
if (this.isBusy || !this.AllowRestore)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var selectionResponse = await this.RustService.SelectFile(T("Select a backup"), [FileTypes.BACKUP_ARCHIVE]);
|
||||
if (selectionResponse.UserCancelled)
|
||||
return;
|
||||
|
||||
this.selectedArchivePath = selectionResponse.SelectedFilePath;
|
||||
this.importResult = null;
|
||||
this.preview = await ChatArchiveImporter.ReadPreviewAsync(this.selectedArchivePath, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
|
||||
// The lockable button calls this as a plain function, not as an event of this
|
||||
// component, so nothing renders the preview unless we do:
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks whether to replace all data with those of the chosen backup, and prepares the restore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once the restore is prepared, the main layout asks for the restart and blocks everything else
|
||||
/// until then: whatever changes meanwhile would be lost.
|
||||
/// </remarks>
|
||||
private async Task StartRestoreAsync()
|
||||
{
|
||||
if (this.isBusy || !this.CanRestore)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, T("Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?") },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Replace All Data"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
await this.DataRestoreService.PrepareRestoreAsync(this.selectedArchivePath);
|
||||
}
|
||||
|
||||
private async Task StartImportAsync()
|
||||
{
|
||||
if (this.isBusy || !this.CanImport)
|
||||
return;
|
||||
|
||||
this.isBusy = true;
|
||||
try
|
||||
{
|
||||
this.importResult = null;
|
||||
this.isImporting = true;
|
||||
this.importProgress = new(0, this.preview!.Manifest.TotalChatCount);
|
||||
this.importCancellation = new();
|
||||
|
||||
var archivePath = this.selectedArchivePath;
|
||||
var token = this.importCancellation.Token;
|
||||
var reporter = this.CreateImportProgressReporter();
|
||||
this.importResult = await Task.Run(() => ChatArchiveImporter.ImportAsync(archivePath, this.collisionBehavior, reporter, token), token);
|
||||
|
||||
//
|
||||
// Drop the preview once every chat was added. Otherwise, another click on the button
|
||||
// would silently add all of them a second time:
|
||||
//
|
||||
if (this.importResult is { Success: true, Cancelled: false })
|
||||
{
|
||||
this.preview = null;
|
||||
this.selectedArchivePath = string.Empty;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.Logger.LogInformation("The user stopped adding the chats of a backup.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isImporting = false;
|
||||
this.DisposeImportCancellation();
|
||||
this.isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelImport() => this.importCancellation?.Cancel();
|
||||
|
||||
/// <summary>
|
||||
/// Detaches the cancellation source before disposing it, so that a late click on the cancel
|
||||
/// button cannot reach a disposed source.
|
||||
/// </summary>
|
||||
private void DisposeImportCancellation()
|
||||
{
|
||||
var cancellation = this.importCancellation;
|
||||
this.importCancellation = null;
|
||||
|
||||
cancellation?.Dispose();
|
||||
}
|
||||
|
||||
private IProgress<ChatArchiveProgress> CreateImportProgressReporter()
|
||||
{
|
||||
this.lastImportRender = DateTimeOffset.MinValue;
|
||||
return new Progress<ChatArchiveProgress>(value =>
|
||||
{
|
||||
this.importProgress = value;
|
||||
|
||||
// Render at most every PROGRESS_RENDER_INTERVAL, but never skip the final state:
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (value.ProcessedChats < value.TotalChats && now - this.lastImportRender < PROGRESS_RENDER_INTERVAL)
|
||||
return;
|
||||
|
||||
this.lastImportRender = now;
|
||||
this.InvokeAsync(this.StateHasChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@using AIStudio.Tools.DataBackup
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@{
|
||||
var organizationPluginFolders = OrganizationPluginFolders;
|
||||
}
|
||||
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||
@this.ExpertNotice
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Explanation")
|
||||
</MudText>
|
||||
<MudJustifiedMarkdown Value="@T("AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager.")"/>
|
||||
|
||||
@foreach (var location in LOCATIONS)
|
||||
{
|
||||
var folder = FolderPath(location);
|
||||
<MudText Typo="Typo.h4">
|
||||
@this.FolderName(location)
|
||||
</MudText>
|
||||
<MudList T="string" Class="mb-3">
|
||||
<MudListItem T="string" Icon="@Icons.Material.Outlined.Folder">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1" Style="width: 100%; min-width: 0;">
|
||||
<MudLink OnClick="@(() => this.CopyPath(folder))" Style="min-width: 0; overflow-wrap: anywhere;">
|
||||
@folder
|
||||
</MudLink>
|
||||
<MudTooltip Text="@T("Open in your file manager")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen"
|
||||
Size="Size.Medium"
|
||||
Disabled="@string.IsNullOrWhiteSpace(folder)"
|
||||
OnClick="@(() => this.OpenInFileManager(folder))"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
</MudList>
|
||||
}
|
||||
|
||||
@if (IsDataFolderInsideConfigFolder)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||
@T("On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along.")
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("What belongs into a backup")
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="@true" Bordered="@true" Striped="@true" Class="mb-3" Style="overflow-x: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Folder")</th>
|
||||
<th>@T("Path")</th>
|
||||
<th>@T("Content")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in INCLUDED_ENTRIES)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(entry.Location)</td>
|
||||
<td><code>@entry.RelativePath</code></td>
|
||||
<td>@this.Describe(entry.Kind)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("What a backup can leave out")
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="@true" Bordered="@true" Striped="@true" Class="mb-3" Style="overflow-x: auto;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Folder")</th>
|
||||
<th>@T("Path")</th>
|
||||
<th>@T("Reason")</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in EXCLUDED_ENTRIES)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(entry.Location)</td>
|
||||
<td><code>@entry.RelativePath</code></td>
|
||||
<td>@this.Describe(entry.Kind)</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var pluginFolder in organizationPluginFolders)
|
||||
{
|
||||
<tr>
|
||||
<td>@this.FolderName(DataBackupLocation.DATA_DIRECTORY)</td>
|
||||
<td><code>@pluginFolder</code></td>
|
||||
<td>@this.Describe(DataBackupEntryKind.ORGANIZATION_PLUGINS)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
<MudText Typo="Typo.h4">
|
||||
@T("Good to know")
|
||||
</MudText>
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew.")
|
||||
</MudJustifiedText>
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more.")
|
||||
</MudJustifiedText>
|
||||
@if (organizationPluginFolders.Count > 0)
|
||||
{
|
||||
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
|
||||
@T("Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the data and configuration folders of AI Studio, and which of their contents belong into a backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For users who back up their computer with a tool of their own. Everything shown here comes from
|
||||
/// the same list the backups of AI Studio follow. It stays complete even when the organization does not
|
||||
/// allow backups within AI Studio: that decides about the backups AI Studio makes, not about which
|
||||
/// folders a user may copy.
|
||||
/// </remarks>
|
||||
public partial class DataFolders : MSGComponentBase
|
||||
{
|
||||
private static readonly DataBackupLocation[] LOCATIONS = [DataBackupLocation.DATA_DIRECTORY, DataBackupLocation.CONFIG_DIRECTORY];
|
||||
|
||||
private static readonly IReadOnlyList<DataBackupEntry> INCLUDED_ENTRIES = DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded).ToList();
|
||||
|
||||
private static readonly IReadOnlyList<DataBackupEntry> EXCLUDED_ENTRIES = DataBackupContents.ENTRIES.Where(entry => !entry.IsIncluded).ToList();
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<DataFolders> Logger { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tells everybody but experts where to go instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pointing to the backups of AI Studio makes sense only while the organization allows them.
|
||||
/// </remarks>
|
||||
private string ExpertNotice
|
||||
{
|
||||
get
|
||||
{
|
||||
var notice = T("The following is meant for experts who back up their computer with a tool of their own.");
|
||||
return this.SettingsManager.ConfigurationData.App.AllowUserToBackUpData
|
||||
? $"{notice} {T("To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead.")}"
|
||||
: notice;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDataFolderInsideConfigFolder =>!string.IsNullOrWhiteSpace(SettingsManager.ConfigDirectory)
|
||||
&& !string.IsNullOrWhiteSpace(SettingsManager.DataDirectory)
|
||||
&& PathTools.IsInsideDirectory(SettingsManager.ConfigDirectory, SettingsManager.DataDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// The plugins which the organization deployed among the user's own ones, as AI Studio found them when it loaded the plugins last.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> OrganizationPluginFolders => string.IsNullOrWhiteSpace(SettingsManager.DataDirectory)
|
||||
? []
|
||||
: DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), SettingsManager.DataDirectory);
|
||||
|
||||
private static string FolderPath(DataBackupLocation location) => location switch
|
||||
{
|
||||
DataBackupLocation.DATA_DIRECTORY => SettingsManager.DataDirectory ?? string.Empty,
|
||||
DataBackupLocation.CONFIG_DIRECTORY => SettingsManager.ConfigDirectory ?? string.Empty,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string FolderName(DataBackupLocation location) => location switch
|
||||
{
|
||||
DataBackupLocation.DATA_DIRECTORY => T("Data folder"),
|
||||
DataBackupLocation.CONFIG_DIRECTORY => T("Configuration folder"),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string Describe(DataBackupEntryKind kind) => kind switch
|
||||
{
|
||||
DataBackupEntryKind.SETTINGS => T("Your settings."),
|
||||
DataBackupEntryKind.WORKSPACES => T("Your chats in workspaces, along with their transcripts."),
|
||||
DataBackupEntryKind.TEMPORARY_CHATS => T("Your temporary chats, along with their transcripts."),
|
||||
DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."),
|
||||
DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."),
|
||||
DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."),
|
||||
DataBackupEntryKind.DATA_SOURCE_INDEX => T("The index of your local data sources: which files AI Studio embedded, and their text."),
|
||||
DataBackupEntryKind.VECTOR_STORES => T("The embeddings of your local data sources."),
|
||||
|
||||
DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS => T("Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."),
|
||||
DataBackupEntryKind.BUILT_IN_PLUGINS => T("Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."),
|
||||
DataBackupEntryKind.ORGANIZATION_PLUGINS => T("Plugins of your organization. Your organization brings them back."),
|
||||
DataBackupEntryKind.TEMPORARY_FILES => T("Files which AI Studio needs only for a moment and deletes again."),
|
||||
DataBackupEntryKind.DOWNLOADED_TOOLS => T("Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again."),
|
||||
DataBackupEntryKind.INTERFACE_KEYS => T("Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones."),
|
||||
DataBackupEntryKind.LOGS => T("Log files. They help when something goes wrong, but they hold none of your data."),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private async Task CopyPath(string path) => await this.RustService.CopyText2Clipboard(path);
|
||||
|
||||
private async Task OpenInFileManager(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Folder, T("The folder is not known yet.")));
|
||||
return;
|
||||
}
|
||||
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open the folder '{Path}' in the file manager.", path);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Folder, T("Could not open the folder.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Folder, string.Format(T("Could not open the folder: {0}"), issue)));
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,9 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
if (dataSource.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
if (await this.RefuseWhileIndexingPaused(T("Delete Data Source")))
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type '{1}'?"), dataSource.Name, dataSource.Type.GetDisplayName()) },
|
||||
@@ -457,6 +460,21 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells the user to wait while a backup copies the indexes or a restore waits for the restart:
|
||||
/// deleting a data source discards its index, which waits for the end of the pause.
|
||||
/// </summary>
|
||||
/// <param name="title">The title of the action which waits.</param>
|
||||
/// <returns>True when the action has to wait.</returns>
|
||||
private async Task<bool> RefuseWhileIndexingPaused(string title)
|
||||
{
|
||||
if (!this.DataSourceEmbeddingService.IsIndexingPaused)
|
||||
return false;
|
||||
|
||||
await this.DialogService.ShowMessageBox(title, T("AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."), T("Close"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edits a row of the table, which is either a data source or a mailbox.
|
||||
/// </summary>
|
||||
@@ -533,6 +551,9 @@ public partial class DataSourceManagement : MSGComponentBase
|
||||
if (mailbox.IsEnterpriseConfiguration)
|
||||
return;
|
||||
|
||||
if (await this.RefuseWhileIndexingPaused(T("Delete Mailbox")))
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(T("Are you sure you want to delete the mailbox '{0}'? Your mails stay on the server as they are. AI Studio only deletes its index of them and the stored password."), mailbox.Name) },
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Start" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RestartAlt" Color="Color.Warning" Size="Size.Large"/>
|
||||
<MudJustifiedText Typo="Typo.body1">
|
||||
@T("Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now.")
|
||||
</MudJustifiedText>
|
||||
</MudStack>
|
||||
@if (this.restartFailed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-4">
|
||||
@T("AI Studio could not restart by itself. Please quit AI Studio and start it again.")
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.DiscardAsync" Variant="Variant.Text" Disabled="@this.isWorking">
|
||||
@T("Discard the restore")
|
||||
</MudButton>
|
||||
<MudButton OnClick="@this.RestartAsync" Variant="Variant.Filled" Color="Color.Primary" Disabled="@this.isWorking">
|
||||
@T("Restart now")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,60 @@
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Blocks AI Studio while a prepared restore waits for the restart which applies it: whatever the user
|
||||
/// changed until then would be lost. The user restarts, or discards the restore.
|
||||
/// </summary>
|
||||
public partial class DataRestorePendingDialog : MSGComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataRestoreService DataRestoreService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
private bool isWorking;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the runtime could not restart, e.g., in the development environment, where it did not
|
||||
/// start the .NET server.
|
||||
/// </summary>
|
||||
private bool restartFailed;
|
||||
|
||||
private async Task RestartAsync()
|
||||
{
|
||||
this.isWorking = true;
|
||||
try
|
||||
{
|
||||
// On success, AI Studio ends right away, and this dialog with it:
|
||||
this.restartFailed = !await this.RustService.RestartApplication();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isWorking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DiscardAsync()
|
||||
{
|
||||
this.isWorking = true;
|
||||
try
|
||||
{
|
||||
await this.DataRestoreService.DiscardPendingRestoreAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.isWorking = false;
|
||||
}
|
||||
|
||||
if (!this.DataRestoreService.IsRestorePending)
|
||||
this.MudDialog.Close(DialogResult.Ok(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@using AIStudio.Tools.DataBackup
|
||||
@inherits MSGComponentBase
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Start" Spacing="3" Class="mb-3">
|
||||
<MudIcon Icon="@this.Icon" Color="@this.IconColor" Size="Size.Large"/>
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.h6">
|
||||
@this.Headline
|
||||
</MudText>
|
||||
<MudJustifiedText Typo="Typo.body1">
|
||||
@this.Explanation
|
||||
</MudJustifiedText>
|
||||
@if (!string.IsNullOrWhiteSpace(this.BackupDescription))
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body2">
|
||||
@this.BackupDescription
|
||||
</MudJustifiedText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@if (this.Report.Outcome is DataRestoreOutcome.APPLIED)
|
||||
{
|
||||
@if (this.OpenPoints.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Variant="Variant.Outlined">
|
||||
@T("Nothing else to do: every provider and every data source has what it needs on this computer.")
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1">
|
||||
@T("A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:")
|
||||
</MudJustifiedText>
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var openPoint in this.OpenPoints)
|
||||
{
|
||||
<MudListItem T="string" Icon="@Icons.Material.Filled.ChecklistRtl" IconColor="Color.Warning">@this.Describe(openPoint)</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(this.Report.Detail))
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.caption">
|
||||
@string.Format(T("Technical details: {0}"), this.Report.Detail)
|
||||
</MudJustifiedText>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@T("Close")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the user, at the start after a restore, how it went, and what remains to do: entering the API
|
||||
/// keys and passwords again, for example, since a backup holds none.
|
||||
/// </summary>
|
||||
public partial class DataRestoreReportDialog : MSGComponentBase
|
||||
{
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// How the runtime applied the restore.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public DataRestoreReport Report { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// What remains to do after a restore which was applied.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<DataRestoreOpenPoint> OpenPoints { get; set; } = [];
|
||||
|
||||
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
this.currentCultureInfo = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string Icon => this.Report.Outcome switch
|
||||
{
|
||||
DataRestoreOutcome.APPLIED => Icons.Material.Filled.CheckCircle,
|
||||
DataRestoreOutcome.ROLLBACK_FAILED => Icons.Material.Filled.Error,
|
||||
_ => Icons.Material.Filled.WarningAmber,
|
||||
};
|
||||
|
||||
private Color IconColor => this.Report.Outcome switch
|
||||
{
|
||||
DataRestoreOutcome.APPLIED => Color.Success,
|
||||
DataRestoreOutcome.ROLLBACK_FAILED => Color.Error,
|
||||
_ => Color.Warning,
|
||||
};
|
||||
|
||||
private string Headline => this.Report.Outcome switch
|
||||
{
|
||||
DataRestoreOutcome.APPLIED => T("Your backup was restored"),
|
||||
DataRestoreOutcome.ROLLBACK_FAILED => T("Restoring your backup is not finished"),
|
||||
_ => T("Your backup was not restored"),
|
||||
};
|
||||
|
||||
private string Explanation => this.Report.Outcome switch
|
||||
{
|
||||
DataRestoreOutcome.APPLIED => T("AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were."),
|
||||
DataRestoreOutcome.REFUSED => T("AI Studio could not start restoring your backup, so all your data stayed as they were."),
|
||||
DataRestoreOutcome.ROLLED_BACK => T("Restoring your backup failed. AI Studio put all your data back the way they were before."),
|
||||
DataRestoreOutcome.ROLLBACK_FAILED => T("Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder."),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string BackupDescription
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(this.Report.BackupCreatedAtUtc, CultureInfo.InvariantCulture, DateTimeStyles.None, out var createdAt))
|
||||
return string.Empty;
|
||||
|
||||
return string.Format(T("The backup is from {0} and was made with AI Studio {1}."), CommonTools.FormatTimestampToGeneral(createdAt.ToLocalTime().DateTime, this.currentCultureInfo), this.Report.BackupAppVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private string Describe(DataRestoreOpenPoint openPoint) => openPoint.Kind switch
|
||||
{
|
||||
DataRestoreOpenPointKind.PROVIDER_WITHOUT_API_KEY => string.Format(T("Enter the API key of the provider '{0}' again, if it needs one."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.EMBEDDING_PROVIDER_WITHOUT_API_KEY => string.Format(T("Enter the API key of the embedding provider '{0}' again, if it needs one."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.TRANSCRIPTION_PROVIDER_WITHOUT_API_KEY => string.Format(T("Enter the API key of the transcription provider '{0}' again, if it needs one."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.DATA_SOURCE_WITHOUT_SECRET => string.Format(T("Enter the password or token of the data source '{0}' again."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.MAILBOX_WITHOUT_PASSWORD => string.Format(T("Enter the password of the mailbox '{0}' again."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.TOOL_WITHOUT_SECRET => string.Format(T("Enter the setting '{1}' of the tool '{0}' again, if you used it."), openPoint.Name, openPoint.Setting),
|
||||
DataRestoreOpenPointKind.DATA_SOURCE_NOT_FOUND => string.Format(T("The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.BATCH_PROCESSING_PATH_NOT_FOUND => string.Format(T("The batch processing uses '{0}', which does not exist on this computer."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.CERTIFICATE_BUNDLE_NOT_FOUND => string.Format(T("The file with your own root certificates, '{0}', does not exist on this computer."), openPoint.Name),
|
||||
DataRestoreOpenPointKind.ERI_SERVER_DIRECTORY_NOT_FOUND => string.Format(T("The ERI server '{0}' writes into a folder which does not exist on this computer."), openPoint.Name),
|
||||
_ => openPoint.Name,
|
||||
};
|
||||
|
||||
private void Close() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
@@ -58,6 +58,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
[Inject]
|
||||
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private DataRestoreService DataRestoreService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private CircuitStateService CircuitState { get; init; } = null!;
|
||||
|
||||
@@ -82,6 +85,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
private bool settingsWriteProtectionWarningShown;
|
||||
private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1);
|
||||
private readonly SemaphoreSlim promptInjectionDialogSemaphore = new(1, 1);
|
||||
private readonly SemaphoreSlim dataRestorePendingDialogSemaphore = new(1, 1);
|
||||
|
||||
private DataSourceEmbeddingOverview embeddingOverview = new(DataSourceEmbeddingState.COMPLETED, 0, 0, 0);
|
||||
private IReadOnlyCollection<NavBarItem> navItems = [];
|
||||
@@ -94,6 +98,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
{
|
||||
this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChanging);
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
this.DataRestoreService.StateChanged += this.OnDataRestoreStateChanged;
|
||||
|
||||
//
|
||||
// We use the Tauri API (Rust) to get the data and config directories
|
||||
@@ -391,7 +396,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
case Event.STARTUP_COMPLETED:
|
||||
this.startupCompleted = true;
|
||||
this.EnsureMandatoryInfosAcceptedAsync().Observe($"{nameof(MainLayout)}: mandatory infos after the startup");
|
||||
this.ShowStartupDialogsAsync().Observe($"{nameof(MainLayout)}: dialogs after the startup");
|
||||
break;
|
||||
|
||||
case Event.RAG_EMBEDDING_STATUS_CHANGED:
|
||||
@@ -640,6 +645,70 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows what the user has to see once the plugins started: the mandatory infos first, then how a
|
||||
/// restore of a backup went.
|
||||
/// </summary>
|
||||
private async Task ShowStartupDialogsAsync()
|
||||
{
|
||||
await this.EnsureMandatoryInfosAcceptedAsync();
|
||||
await this.ShowDataRestoreReportAsync();
|
||||
await this.ShowDataRestorePendingDialogAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the restart as soon as a restore is prepared.
|
||||
/// </summary>
|
||||
private void OnDataRestoreStateChanged()
|
||||
{
|
||||
if (this.DataRestoreService.IsRestorePending)
|
||||
this.InvokeAsync(this.ShowDataRestorePendingDialogAsync).Observe($"{nameof(MainLayout)}: asking for the restart which applies a restore");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks AI Studio while a prepared restore waits for the restart, since whatever changes until
|
||||
/// then would be lost.
|
||||
/// </summary>
|
||||
private async Task ShowDataRestorePendingDialogAsync()
|
||||
{
|
||||
if (!await this.dataRestorePendingDialogSemaphore.WaitAsync(0))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await this.DataRestoreService.StartupStateLoaded;
|
||||
if (!this.DataRestoreService.IsRestorePending)
|
||||
return;
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<DataRestorePendingDialog>(T("Restore of a Backup"), DialogOptions.BLOCKING_FULLSCREEN);
|
||||
await dialogReference.Result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.dataRestorePendingDialogSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells how the runtime applied a restore at this start, and what remains to do.
|
||||
/// </summary>
|
||||
private async Task ShowDataRestoreReportAsync()
|
||||
{
|
||||
await this.DataRestoreService.StartupStateLoaded;
|
||||
if (this.DataRestoreService.Report is not { } report)
|
||||
return;
|
||||
|
||||
var dialogParameters = new DialogParameters<DataRestoreReportDialog>
|
||||
{
|
||||
{ x => x.Report, report },
|
||||
{ x => x.OpenPoints, await this.DataRestoreService.CollectOpenPointsAsync() },
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<DataRestoreReportDialog>(T("Restore of a Backup"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
await dialogReference.Result;
|
||||
this.DataRestoreService.ForgetReport();
|
||||
}
|
||||
|
||||
private async Task EnsureMandatoryInfosAcceptedAsync()
|
||||
{
|
||||
if (!await this.mandatoryInfoDialogSemaphore.WaitAsync(0))
|
||||
@@ -722,9 +791,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
public void Dispose()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.DataRestoreService.StateChanged -= this.OnDataRestoreStateChanged;
|
||||
this.CircuitState.ConnectionRestored -= this.OnConnectionRestored;
|
||||
this.MessageBus.Unregister(this);
|
||||
this.mandatoryInfoDialogSemaphore.Dispose();
|
||||
this.dataRestorePendingDialogSemaphore.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -340,6 +340,14 @@
|
||||
</MudList>
|
||||
</ExpansionPanel>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.SettingsBackupRestore" HeaderText="@T("Data Backup & Restore")">
|
||||
<DataBackupPanel/>
|
||||
</ExpansionPanel>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.FolderCopy" HeaderText="@T("Your Data Folders")">
|
||||
<DataFolders/>
|
||||
</ExpansionPanel>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Engineering" HeaderText="@T("Motivation")">
|
||||
<Motivation/>
|
||||
</ExpansionPanel>
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
|
||||
<SettingsPanelAgentContentCleaner AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
||||
<SettingsPanelAgentAssistantAudit AvailableLLMProvidersFunc="@(() => this.availableLLMProviders)"/>
|
||||
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.SettingsBackupRestore" HeaderText="@T("Data Backup & Restore")">
|
||||
<DataBackupPanel/>
|
||||
</ExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</InnerScrolling>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
<Supporter Name="PaulKoudelka" Type="SupporterType.INDIVIDUAL" URL="https://github.com/PaulKoudelka" Acknowledgment="@T("Thank you, Paul, for building local RAG step by step, from the vector database to the first beta, and for the upgrade to Tauri 2.")"/>
|
||||
<Supporter Name="OliverKunc" Type="SupporterType.INDIVIDUAL" URL="https://github.com/OliverKunc" Acknowledgment="@T("Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI.")"/>
|
||||
<Supporter Name="donework" Type="SupporterType.INDIVIDUAL" URL="https://github.com/donework" Acknowledgment="@T("Thank you, Dominic, for supporting us not only financially but also with code: a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search.")"/>
|
||||
<Supporter Name="j-erler" Type="SupporterType.INDIVIDUAL" URL="https://github.com/j-erler" Acknowledgment="@T("Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication.")"/>
|
||||
<Supporter Name="j-erler" Type="SupporterType.INDIVIDUAL" URL="https://github.com/j-erler" Acknowledgment="@T("Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication.")"/>
|
||||
<Supporter Name="SimonBpunkt" Type="SupporterType.INDIVIDUAL" URL="https://github.com/SimonBpunkt" Acknowledgment="@T("Thanks, Simon, for the detection patterns against prompt injection, their translations, and a fix for switching between light and dark mode.")"/>
|
||||
<Supporter Name="prodmanpd" Type="SupporterType.INDIVIDUAL" URL="https://github.com/prodmanpd" Acknowledgment="@T("Thank you, Prodman, for bringing LiteLLM to AI Studio as a new provider.")"/>
|
||||
<Supporter Name="Thibaultjaigu" Type="SupporterType.INDIVIDUAL" URL="https://github.com/Thibaultjaigu" Acknowledgment="@T("Thanks, Thibault, for bringing Requesty to AI Studio as a new provider.")"/>
|
||||
|
||||
@@ -64,19 +64,18 @@ This example shows:
|
||||
Treat the example as the recommended minimum viable pattern for assistant plugins, not as a feature-by-feature clone of `AssistantTranslation.razor`.
|
||||
|
||||
## Directory Structure
|
||||
Each assistant plugin lives in its own directory under the assistants plugin root. In practice, you usually keep the manifest in `plugin.lua`, optional icon rendering in `icon.lua`, and any bundled media in `assets/`.
|
||||
Each assistant plugin lives in its own directory under the assistants plugin root, `plugins/assistants/` in the data directory of AI Studio. The panel **Your Data Folders** on the Information page shows where the data directory lies on your computer. In practice, you usually keep the manifest in `plugin.lua`, optional icon rendering in `icon.lua`, and any bundled media in `assets/`.
|
||||
|
||||
```
|
||||
.
|
||||
└── com.github.mindwork-ai.ai-studio/
|
||||
└── data/
|
||||
└── plugins/
|
||||
└── assistants/
|
||||
└── your-assistant-directory/
|
||||
├── assets/
|
||||
│ └── your-media-files.jpg
|
||||
├── icon.lua
|
||||
└── plugin.lua
|
||||
└── <data directory>/
|
||||
└── plugins/
|
||||
└── assistants/
|
||||
└── your-assistant-directory/
|
||||
├── assets/
|
||||
│ └── your-media-files.jpg
|
||||
├── icon.lua
|
||||
└── plugin.lua
|
||||
```
|
||||
|
||||
A plugin may split its Lua code into several files and load them with `require`. Name the file relative to the plugin directory and without `.lua`, and separate subdirectories with a slash: `require("icon")` loads `icon.lua`, and `require("lib/helpers")` loads `lib/helpers.lua`. A plugin can only load files from its own directory, and `dofile` and `loadfile` are not available.
|
||||
|
||||
@@ -452,6 +452,18 @@ CONFIG["SETTINGS"] = {}
|
||||
-- On Linux, this button exports the plugin archive instead of using a native share sheet.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AllowUserToSharePlugins"] = false
|
||||
|
||||
-- Configure the user permission to back up the data of AI Studio into a file, e.g. to take it
|
||||
-- to a new computer. When set to false, the backup button stays visible but is disabled.
|
||||
-- A backup holds chats and other confidential content in a single file, which can leave the
|
||||
-- computer on any stick or cloud drive. Secrets such as API keys never go into a backup.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AllowUserToBackUpData"] = false
|
||||
|
||||
-- Configure the user permission to restore the data of AI Studio from a backup file, or to add
|
||||
-- the chats of a backup. When set to false, the restore button stays visible but is disabled.
|
||||
-- A backup brings along the locations of documents and local data sources, and those are read
|
||||
-- again afterward.
|
||||
-- CONFIG["SETTINGS"]["DataApp.AllowUserToRestoreData"] = false
|
||||
|
||||
-- Configure whether administration settings are visible in the UI:
|
||||
-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true
|
||||
|
||||
|
||||
+408
-9
@@ -3774,6 +3774,237 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "T
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Tastaturkurzbefehl konfigurieren"
|
||||
|
||||
-- Keep the existing chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1274903302"] = "Vorhandenen Chat behalten"
|
||||
|
||||
-- The backup goes on when you leave this page.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1298413158"] = "Die Sicherung läuft weiter, wenn Sie diese Seite verlassen."
|
||||
|
||||
-- Unpacking file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1496386567"] = "Datei {0} von {1} wird entpackt"
|
||||
|
||||
-- Created on {0} with AI Studio {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1503285113"] = "Erstellt am {0} mit AI Studio {1}"
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1555678613"] = "AI Studio hat die Wiederherstellung einer früheren Sicherung noch nicht abgeschlossen. Bitte starten Sie AI Studio zuerst neu."
|
||||
|
||||
-- Replace All Data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1670691112"] = "Alle Daten ersetzen"
|
||||
|
||||
-- Reading the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1731783566"] = "Sicherung wird gelesen"
|
||||
|
||||
-- Please stay on this page until this is done. Leaving the page stops adding the chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1758163038"] = "Bitte bleiben Sie auf dieser Seite, bis der Vorgang abgeschlossen ist. Wenn Sie die Seite verlassen, werden keine weiteren Chats hinzugefügt."
|
||||
|
||||
-- Back up
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1823019579"] = "Sichern"
|
||||
|
||||
-- When a chat already exists
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2117204104"] = "Wenn ein Chat bereits vorhanden ist"
|
||||
|
||||
-- Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2176422849"] = "Beim Wiederherstellen dieser Sicherung werden Ihre Einstellungen, Chats, Plugins und die Indexe Ihrer Datenquellen durch die der Sicherung ersetzt. Alles, was Sie seit dem Erstellen der Sicherung geändert haben, geht verloren. Anschließend startet AI Studio neu. Möchten Sie diese Sicherung wiederherstellen?"
|
||||
|
||||
-- 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2301695346"] = "1 Chat"
|
||||
|
||||
-- Writing file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2416389933"] = "Datei {0} von {1} wird geschrieben"
|
||||
|
||||
-- Temporary chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2532369939"] = "Temporäre Chats"
|
||||
|
||||
-- Adding the chats was stopped. The chats added until then were kept.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2586532370"] = "Das Hinzufügen der Chats wurde abgebrochen. Die bis dahin hinzugefügten Chats bleiben erhalten."
|
||||
|
||||
-- Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T267943793"] = "Wiederherstellen"
|
||||
|
||||
-- Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2693525709"] = "Ersetzt Ihre Einstellungen, Chats, Plugins und die Indexe Ihrer Datenquellen durch die der Sicherung. AI Studio startet dafür neu. Die Plugins und Einstellungen Ihrer Organisation bleiben unverändert."
|
||||
|
||||
-- Chats which already existed and stayed as they are: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T29276052"] = "Chats, die bereits vorhanden waren und unverändert geblieben sind: {0}."
|
||||
|
||||
-- The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3006171489"] = "Die Indexe Ihrer Datenquellen sind nicht in dieser Sicherung enthalten, weil sie nicht verfügbar waren. Nach einer Wiederherstellung indexiert AI Studio Ihre Datenquellen neu."
|
||||
|
||||
-- Preparing the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3082169282"] = "Sicherung wird vorbereitet"
|
||||
|
||||
-- Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3087579229"] = "Sichern Sie alle Ihre Daten in einer einzigen Datei: Ihre Einstellungen, Chats, Plugins und die Indexe Ihrer Datenquellen. Ihre API-Schlüssel und Passwörter werden nicht mitgesichert. AI Studio bewahrt sie im sicheren Speicher Ihres Betriebssystems auf."
|
||||
|
||||
-- Choose backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3114985646"] = "Sicherung auswählen"
|
||||
|
||||
-- Select a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T311951952"] = "Sicherung auswählen"
|
||||
|
||||
-- Add chats only
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3238328507"] = "Nur Chats hinzufügen"
|
||||
|
||||
-- Chat {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T355446621"] = "Chat {0} von {1}"
|
||||
|
||||
-- {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3562573794"] = "{0} Chats"
|
||||
|
||||
-- Add {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3585479613"] = "{0} Chats hinzufügen"
|
||||
|
||||
-- Backed up {0} files into: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3630348902"] = "{0} Dateien wurden gesichert in: {1}"
|
||||
|
||||
-- Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3657899737"] = "Stellen Sie eine Sicherung wieder her: Ersetzen Sie alle Ihre Daten durch die der Sicherung, z. B. auf einem neuen Rechner, oder fügen Sie nur ihre Chats zu Ihren vorhandenen Chats hinzu."
|
||||
|
||||
-- Add it as an additional chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3966117123"] = "Als zusätzlichen Chat hinzufügen"
|
||||
|
||||
-- Chats added: {0}. Workspaces they went into: {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3972560517"] = "Hinzugefügte Chats: {0}. Betroffene Arbeitsbereiche: {1}."
|
||||
|
||||
-- The backup was stopped. No file was left behind.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T431814261"] = "Die Sicherung wurde abgebrochen. Es ist keine Datei zurückgeblieben."
|
||||
|
||||
-- Adds the chats of the backup to the chats you have. Nothing else changes.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T457177394"] = "Fügt die Chats der Sicherung zu Ihren vorhandenen Chats hinzu. Alles andere bleibt unverändert."
|
||||
|
||||
-- Chats which could not be added: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T611768714"] = "Chats, die nicht hinzugefügt werden konnten: {0}."
|
||||
|
||||
-- The restore was stopped. All your data stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T749188343"] = "Die Wiederherstellung wurde abgebrochen. Alle Ihre Daten bleiben unverändert."
|
||||
|
||||
-- Back up data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T796613169"] = "Daten sichern"
|
||||
|
||||
-- Replace all data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T819832488"] = "Alle Daten ersetzen"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T900713019"] = "Abbrechen"
|
||||
|
||||
-- Copying the indexes of your data sources: {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T907141309"] = "Die Indexe Ihrer Datenquellen werden kopiert: {0} von {1}"
|
||||
|
||||
-- Add 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T938996955"] = "1 Chat hinzufügen"
|
||||
|
||||
-- Folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T104168189"] = "Ordner"
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1093747001"] = "Grund"
|
||||
|
||||
-- Configuration folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1191404447"] = "Konfigurationsordner"
|
||||
|
||||
-- Could not open the folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1215994612"] = "Der Ordner konnte nicht geöffnet werden."
|
||||
|
||||
-- Your projects in the Visual Briefing Assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Ihre Projekte im Assistenten für visuelle Briefings."
|
||||
|
||||
-- Your temporary chats, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Ihre temporären Chats mit ihren Transkripten."
|
||||
|
||||
-- What belongs into a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "Was in eine Sicherung gehört"
|
||||
|
||||
-- The index of your local data sources: which files AI Studio embedded, and their text.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T170332953"] = "Der Index Ihrer lokalen Datenquellen: welche Dateien AI Studio eingebettet hat und deren Text."
|
||||
|
||||
-- Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1981169629"] = "Schlüssel, mit denen AI Studio Daten in seiner Benutzeroberfläche schützt. Nur die Flatpak-Version legt sie hier ab, und auf einem anderen Rechner erstellt AI Studio neue."
|
||||
|
||||
-- Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1992121437"] = "Werkzeuge, die AI Studio für dieses Betriebssystem heruntergeladen hat, z. B. Pandoc. Auf einem anderen Rechner lädt AI Studio sie erneut herunter."
|
||||
|
||||
-- What a backup can leave out
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2120934651"] = "Was eine Sicherung auslassen kann"
|
||||
|
||||
-- Good to know
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Gut zu wissen"
|
||||
|
||||
-- Data folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Datenordner"
|
||||
|
||||
-- Explanation
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Erklärung"
|
||||
|
||||
-- Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2851981238"] = "Kopien Ihrer Einstellungen, die jede Version von AI Studio für sich anlegt. Für eine Sicherung genügen Ihre aktuellen Einstellungen."
|
||||
|
||||
-- The tokenizers you chose for your providers.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2892362083"] = "Die Tokenizer, die Sie für Ihre Anbieter ausgewählt haben."
|
||||
|
||||
-- To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2906063884"] = "Um Ihre Daten mit AI Studio selbst zu sichern oder auf einen anderen Rechner zu übertragen, verwenden Sie „Datensicherung & Wiederherstellung“ auf dieser Seite oder in den Einstellungen."
|
||||
|
||||
-- Your plugins, such as the assistants you built, apart from the folders listed below.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2979110360"] = "Ihre Plugins, z. B. selbst erstellte Assistenten, mit Ausnahme der unten aufgeführten Ordner."
|
||||
|
||||
-- Open in your file manager
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2986518596"] = "Im Dateimanager öffnen"
|
||||
|
||||
-- Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3156743605"] = "Ihre Organisation hat einige Plugins neben Ihren eigenen bereitgestellt, zum Beispiel über eine Geräteverwaltung. In den Ordnern ist nicht ersichtlich, welche Plugins von Ihrer Organisation stammen. Deshalb werden sie oben einzeln aufgeführt."
|
||||
|
||||
-- Content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3244793698"] = "Inhalt"
|
||||
|
||||
-- The embeddings of your local data sources.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3301102729"] = "Die Einbettungen Ihrer lokalen Datenquellen."
|
||||
|
||||
-- The folder is not known yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3310368654"] = "Der Ordner ist noch nicht bekannt."
|
||||
|
||||
-- Could not open the folder: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3380661810"] = "Der Ordner konnte nicht geöffnet werden: {0}"
|
||||
|
||||
-- Unknown error
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3461425987"] = "Unbekannter Fehler"
|
||||
|
||||
-- Plugins of your organization. Your organization brings them back.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T346886000"] = "Plugins Ihrer Organisation. Ihre Organisation stellt sie wieder bereit."
|
||||
|
||||
-- Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3655837348"] = "Sichern Sie die Indexe Ihrer Datenquellen nur, wenn AI Studio geschlossen ist. Während AI Studio läuft, schreibt es in diese Indexe. Eine Kopie, die währenddessen entsteht, passt deshalb womöglich nicht zusammen, und AI Studio müsste Ihre Datenquellen neu einbetten."
|
||||
|
||||
-- Files which AI Studio needs only for a moment and deletes again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T366353671"] = "Dateien, die AI Studio nur kurz benötigt und anschließend wieder löscht."
|
||||
|
||||
-- Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3877928356"] = "Plugins, die mit AI Studio mitgeliefert werden, etwa Sprachpakete. AI Studio stellt sie automatisch wieder her."
|
||||
|
||||
-- Path
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3949388886"] = "Pfad"
|
||||
|
||||
-- Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T4093107108"] = "Ihre API-Schlüssel, Token und Passwörter befinden sich nicht in diesen Ordnern. AI Studio bewahrt sie im sicheren Speicher Ihres Betriebssystems auf. Nachdem Sie Ihre Daten auf einem anderen Rechner wiederhergestellt haben, geben Sie sie dort erneut ein."
|
||||
|
||||
-- Your chats in workspaces, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T509585668"] = "Ihre Chats in Arbeitsbereichen mit ihren Transkripten."
|
||||
|
||||
-- Log files. They help when something goes wrong, but they hold none of your data.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T53143530"] = "Protokolldateien. Sie helfen, wenn etwas schiefgeht, enthalten aber keine Ihrer Daten."
|
||||
|
||||
-- AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T69622543"] = "AI Studio speichert Ihre Daten in zwei Ordnern auf diesem Rechner. Wenn Sie Ihren Rechner mit einem eigenen Programm sichern, etwa mit [Restic](https://restic.net), [Borg](https://www.borgbackup.org) oder [Time Machine](https://support.apple.com/de-de/104984), sehen Sie hier, welche Inhalte dieser Ordner in die Sicherung gehören und welche sie auslassen kann. Klicken Sie auf einen Pfad, um ihn in die Zwischenablage zu kopieren, oder auf das Ordnersymbol, um ihn in Ihrem Dateimanager zu öffnen."
|
||||
|
||||
-- Your settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T790727583"] = "Ihre Einstellungen."
|
||||
|
||||
-- The following is meant for experts who back up their computer with a tool of their own.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "Das Folgende richtet sich an Experten, die ihren Rechner mit einem eigenen Programm sichern."
|
||||
|
||||
-- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "Auf diesem Rechner befindet sich der Datenordner im Konfigurationsordner. Bei einer Sicherung des Konfigurationsordners wird daher auch der Datenordner mitgesichert."
|
||||
|
||||
-- Yes, please send my data to the external embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Ja, bitte senden Sie meine Daten an den externen Einbettungsanbieter"
|
||||
|
||||
@@ -3810,6 +4041,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T6617545
|
||||
-- All files in this folder and its subfolders
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T916879200"] = "Alle Dateien in diesem Ordner und seinen Unterordnern"
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1006756723"] = "AI Studio sichert gerade die Indexe Ihrer Datenquellen oder stellt sie wieder her. Bitte versuchen Sie es erneut, sobald der Vorgang abgeschlossen ist."
|
||||
|
||||
-- You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "Sie können verschiedene Datenquellen konfigurieren. Eine Datenquelle kann eine einzelne Datei, alle Dateien in einem Ordner oder Daten aus Ihrem Unternehmen enthalten. Später können Sie diese Datenquellen bei Bedarf einbinden, wenn die KI diese Daten zur Erledigung einer bestimmten Aufgabe benötigt."
|
||||
|
||||
@@ -6087,6 +6321,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "Nein"
|
||||
-- Yes
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T3013883440"] = "Ja"
|
||||
|
||||
-- Restart now
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T1409292924"] = "Jetzt neu starten"
|
||||
|
||||
-- Discard the restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T3017406920"] = "Wiederherstellung verwerfen"
|
||||
|
||||
-- Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T495853773"] = "Ihre Sicherung ist bereit. AI Studio startet neu, um sie einzuspielen. Alles, was Sie bis dahin ändern, ginge verloren. Bitte starten Sie AI Studio daher jetzt neu."
|
||||
|
||||
-- AI Studio could not restart by itself. Please quit AI Studio and start it again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T573887863"] = "AI Studio konnte sich nicht selbst neu starten. Bitte beenden Sie AI Studio und starten Sie es erneut."
|
||||
|
||||
-- Restoring your backup failed. AI Studio put all your data back the way they were before.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1057857006"] = "Die Wiederherstellung Ihrer Sicherung ist fehlgeschlagen. AI Studio hat alle Ihre Daten wieder in den Zustand von vorher versetzt."
|
||||
|
||||
-- Your backup was not restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1193456690"] = "Ihre Sicherung wurde nicht wiederhergestellt"
|
||||
|
||||
-- Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T123391329"] = "Die Wiederherstellung Ihrer Sicherung ist fehlgeschlagen, und AI Studio konnte Ihre Daten noch nicht vollständig in den Zustand von vorher versetzen. Beim nächsten Start versucht AI Studio es erneut, bitte starten Sie AI Studio daher neu. Wenn diese Meldung erneut erscheint, wenden Sie sich bitte an den Support und löschen Sie die Ordner „.restore-previous“ und „.restore-pending“ in Ihrem Datenordner nicht."
|
||||
|
||||
-- The batch processing uses '{0}', which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1314976065"] = "Die Stapelverarbeitung verwendet „{0}“, das auf diesem Rechner nicht vorhanden ist."
|
||||
|
||||
-- The backup is from {0} and was made with AI Studio {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1363238678"] = "Die Sicherung stammt vom {0} und wurde mit AI Studio {1} erstellt."
|
||||
|
||||
-- A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1633897333"] = "Eine Sicherung enthält keine API-Schlüssel oder Passwörter, und einige Dateien und Ordner befinden sich nicht auf diesem Rechner. Bitte prüfen Sie Folgendes:"
|
||||
|
||||
-- Enter the API key of the provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1645175373"] = "Geben Sie den API-Schlüssel des Anbieters „{0}“ erneut ein, falls er einen benötigt."
|
||||
|
||||
-- Restoring your backup is not finished
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2415452768"] = "Die Wiederherstellung Ihrer Sicherung ist noch nicht abgeschlossen"
|
||||
|
||||
-- Enter the API key of the embedding provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2492749778"] = "Geben Sie den API-Schlüssel des Einbettungsanbieters „{0}“ erneut ein, falls er einen benötigt."
|
||||
|
||||
-- Enter the API key of the transcription provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2568556297"] = "Geben Sie den API-Schlüssel des Transkriptionsanbieters „{0}“ erneut ein, falls er einen benötigt."
|
||||
|
||||
-- Nothing else to do: every provider and every data source has what it needs on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2671436044"] = "Es ist nichts weiter zu tun: Alle Anbieter und Datenquellen haben auf diesem Rechner, was sie benötigen."
|
||||
|
||||
-- The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T270687216"] = "Die Datenquelle „{0}“ verweist auf einen Pfad, den es auf diesem Rechner nicht gibt. Wählen Sie die Datei oder den Ordner neu aus."
|
||||
|
||||
-- The file with your own root certificates, '{0}', does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2844911500"] = "Die Datei mit Ihren eigenen Stammzertifikaten, „{0}“, existiert auf diesem Rechner nicht."
|
||||
|
||||
-- AI Studio could not start restoring your backup, so all your data stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3044053464"] = "AI Studio konnte die Wiederherstellung Ihrer Sicherung nicht beginnen. Daher sind alle Ihre Daten unverändert geblieben."
|
||||
|
||||
-- Enter the password of the mailbox '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3083231765"] = "Geben Sie das Passwort für das Postfach „{0}“ erneut ein."
|
||||
|
||||
-- Enter the password or token of the data source '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3430781520"] = "Geben Sie das Passwort oder das Token der Datenquelle „{0}“ erneut ein."
|
||||
|
||||
-- Technical details: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3436390766"] = "Technische Details: {0}"
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3448155331"] = "Schließen"
|
||||
|
||||
-- Enter the setting '{1}' of the tool '{0}' again, if you used it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3658964195"] = "Geben Sie die Einstellung „{1}“ des Werkzeugs „{0}“ erneut ein, falls Sie sie verwendet haben."
|
||||
|
||||
-- AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3896631626"] = "AI Studio hat Ihre Daten durch die der Sicherung ersetzt. Die Plugins und Einstellungen Ihrer Organisation sind unverändert geblieben."
|
||||
|
||||
-- Your backup was restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Ihre Sicherung wurde wiederhergestellt"
|
||||
|
||||
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "Der ERI-Server „{0}“ schreibt in einen Ordner, der auf diesem Rechner nicht existiert."
|
||||
|
||||
-- How many matches do you want at most per query?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "Wie viele Treffer möchten Sie maximal pro Abfrage erhalten?"
|
||||
|
||||
@@ -9387,6 +9699,9 @@ UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2800137365"] = "Ein Update auf V
|
||||
-- Please wait for the update to complete...
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2864211629"] = "Bitte warten Sie, bis das Update abgeschlossen ist ..."
|
||||
|
||||
-- Restore of a Backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2912921801"] = "Wiederherstellung einer Sicherung"
|
||||
|
||||
-- Supporters
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2929332068"] = "Unterstützer"
|
||||
|
||||
@@ -10191,6 +10506,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3294830584"] = "Entdecken Sie di
|
||||
-- External HTTPS custom root certificates
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "Externe HTTPS-Stammzertifikate für benutzerdefinierte Zertifizierungsstellen"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3317700277"] = "Datensicherung & Wiederherstellung"
|
||||
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "Vom Betriebssystem bereitgestellte Sprache"
|
||||
|
||||
@@ -10347,6 +10665,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T551035563"] = "Protokollanzeige
|
||||
-- This is a library providing the foundations for asynchronous programming in Rust. It includes key trait definitions like Stream, as well as utilities like join!, select!, and various futures combinator methods which enable expressive asynchronous control flow.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "Dies ist eine Bibliothek, die die Grundlagen für asynchrones Programmieren in Rust bereitstellt. Sie enthält zentrale Trait-Definitionen wie Stream sowie Hilfsfunktionen wie join!, select! und verschiedene Methoden zur Kombination von Futures, die einen ausdrucksstarken asynchronen Kontrollfluss ermöglichen."
|
||||
|
||||
-- Your Data Folders
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T570573187"] = "Ihre Datenordner"
|
||||
|
||||
-- Used .NET SDK
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Verwendetes .NET SDK"
|
||||
|
||||
@@ -10530,15 +10851,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "Das Plugin konnte nic
|
||||
-- Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Einstellungen"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T3317700277"] = "Datensicherung & Wiederherstellung"
|
||||
|
||||
-- Thank you very much, Kerstin, for creating the Wiki, for advising us on ERI with real use cases, and for your work on version 2 of the ERI protocol.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1367356224"] = "Vielen herzlichen Dank, Kerstin, für die Erstellung des Wikis, deine Beratung zu ERI anhand realer Anwendungsfälle und deine Arbeit an Version 2 des ERI-Protokolls."
|
||||
|
||||
-- Thank you for being the first to contribute a one-time donation.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1470916504"] = "Vielen Dank, dass du die erste einmalige Spende geleistet hast."
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1701321325"] = "Vielen Dank, Jens, für den Assistenten für die Stapelverarbeitung, die exakte Tokenanzahl im Chat und alles Weitere, was du mit so viel Hingabe für AI Studio entwickelst."
|
||||
|
||||
-- Thank you, Peer, for your courage in being the second person to support the project financially.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1714878838"] = "Vielen Dank, Peer, für deinen Mut, als zweite Person das Projekt finanziell zu unterstützen."
|
||||
|
||||
@@ -10626,6 +10947,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T686206269"] = "Danke für deinen
|
||||
-- Business Contributors
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T838479287"] = "Spenden von Unternehmen"
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T882777973"] = "Vielen Dank, Jens, für den Assistenten für die Stapelverarbeitung, die exakte Tokenanzahl im Chat, die Sicherung aller Daten und alles Weitere, was du mit so viel Hingabe für AI Studio entwickelst."
|
||||
|
||||
-- Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T916186976"] = "Danke, Oliver, dass du dafür gesorgt hast, dass in Antworten von OpenAI keine Leerzeichen mehr verloren gehen."
|
||||
|
||||
@@ -11268,6 +11592,15 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "Keine
|
||||
-- Username & Password
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Benutzername & Passwort"
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unerwarteter Fehler: {0}"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T3123318432"] = "Diese Datei ist keine Sicherung von AI Studio oder wurde nicht vollständig geschrieben."
|
||||
|
||||
-- Imported workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Importierter Arbeitsbereich"
|
||||
|
||||
-- Spanish (Spain)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanisch (Spanien)"
|
||||
|
||||
@@ -11403,6 +11736,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = "
|
||||
-- Trust all LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Allen LLM-Anbietern vertrauen"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3123318432"] = "Diese Datei ist keine Sicherung von AI Studio oder wurde nicht vollständig geschrieben."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3812849494"] = "Diese Sicherung wurde mit einer neueren Version von AI Studio erstellt. Bitte aktualisieren Sie AI Studio zuerst."
|
||||
|
||||
-- The content list of this backup could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3954689017"] = "Das Inhaltsverzeichnis dieser Sicherung konnte nicht gelesen werden."
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3123318432"] = "Diese Datei ist keine Sicherung von AI Studio oder wurde nicht vollständig geschrieben."
|
||||
|
||||
-- The backup is damaged: the file '{0}' does not match its content list.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3493230602"] = "Die Sicherung ist beschädigt: Die Datei „{0}“ stimmt nicht mit dem Inhaltsverzeichnis überein."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3812849494"] = "Diese Sicherung wurde mit einer neueren Version von AI Studio erstellt. Bitte aktualisieren Sie AI Studio zuerst."
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1093747001"] = "Grund"
|
||||
|
||||
@@ -11553,6 +11904,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T858000918"] = "Dies
|
||||
-- Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T874850580"] = "Alles, was für sie vorbereitet wurde, wird verworfen, und jedes ihrer Dokumente wird erneut an Ihren Einbettungsanbieter gesendet. Bei einer großen Datenquelle dauert dies eine Weile."
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T1006756723"] = "AI Studio sichert gerade die Indexe Ihrer Datenquellen oder stellt sie wieder her. Bitte versuchen Sie es erneut, sobald der Vorgang abgeschlossen ist."
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T3448155331"] = "Schließen"
|
||||
|
||||
-- Repair Data Source
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T4175865785"] = "Datenquelle reparieren"
|
||||
|
||||
@@ -12483,6 +12840,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T377
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
|
||||
-- AI Studio backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1046878067"] = "AI-Studio-Sicherung"
|
||||
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office-Dateien"
|
||||
|
||||
@@ -12744,9 +13104,45 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T5
|
||||
-- Workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T658612054"] = "Arbeitsbereich"
|
||||
|
||||
-- A backup is running already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1319749333"] = "Eine Sicherung läuft bereits."
|
||||
|
||||
-- The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1869616367"] = "Die Sicherung ist zu groß für das ausgewählte Laufwerk, da darauf keine Dateien mit mehr als 4 GB gespeichert werden können. USB-Sticks mit FAT32-Formatierung haben diese Einschränkung. Bitte wählen Sie ein anderes Laufwerk."
|
||||
|
||||
-- The backup failed: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1915001713"] = "Die Sicherung ist fehlgeschlagen: {0}"
|
||||
|
||||
-- Your organization does not allow backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T2011318013"] = "Ihre Organisation erlaubt keine Sicherungen."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please start the backup once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T4161531565"] = "AI Studio schreibt noch eine Antwort oder ein Transkript. Bitte starten Sie die Sicherung, sobald das erledigt ist."
|
||||
|
||||
-- Your organization does not allow restoring backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1032491606"] = "Ihre Organisation erlaubt keine Wiederherstellung von Sicherungen."
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1555678613"] = "AI Studio hat die Wiederherstellung einer früheren Sicherung noch nicht abgeschlossen. Bitte starten Sie AI Studio zuerst neu."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1847516630"] = "AI Studio schreibt noch eine Antwort oder ein Transkript. Bitte starten Sie die Wiederherstellung, sobald das erledigt ist."
|
||||
|
||||
-- The restore could not be prepared: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T2653345012"] = "Die Wiederherstellung konnte nicht vorbereitet werden: {0}"
|
||||
|
||||
-- A restore is being prepared already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T4157700208"] = "Eine Wiederherstellung wird bereits vorbereitet."
|
||||
|
||||
-- A backup is running. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T553866901"] = "Gerade läuft eine Sicherung. Bitte starten Sie die Wiederherstellung, sobald sie abgeschlossen ist."
|
||||
|
||||
-- The local RAG index database is not available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T1738200026"] = "Die lokale RAG-Indexdatenbank ist nicht verfügbar."
|
||||
|
||||
-- The index of the data source '{0}' is missing. The data source stays out of your chats until its index was built anew. Use the repair action to start that.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T204783307"] = "Der Index der Datenquelle „{0}“ fehlt. Die Datenquelle wird in Ihren Chats nicht verwendet, bis ihr Index neu erstellt wurde. Verwenden Sie die Aktion „Reparieren“, um dies zu starten."
|
||||
|
||||
-- The selected embedding provider is not allowed to index this data source. The data source asks for the confidence level '{0}', while the embedding provider has '{1}'.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T2186533187"] = "Der ausgewählte Einbettungsanbieter darf diese Datenquelle nicht indexieren. Die Datenquelle erfordert das Vertrauensniveau „{0}“, während der Einbettungsanbieter „{1}“ hat."
|
||||
|
||||
@@ -12852,6 +13248,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360
|
||||
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "Die E-Mail „{0}“ konnte nicht indexiert werden. AI Studio versucht es bei der nächsten Synchronisierung erneut."
|
||||
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "Der Ordner „{0}“ konnte nicht geöffnet werden. Bitte prüfen Sie, ob Sie die erforderlichen Leserechte haben. Aus dem Index wurde nichts entfernt."
|
||||
|
||||
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "Die Datei „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem sie sich befindet, nicht verbunden, oder die Datei wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Sobald die Datei wieder verfügbar ist, aktualisieren Sie diese Datenquelle. Wenn sie dauerhaft nicht mehr vorhanden ist, entfernen Sie diese Datenquelle."
|
||||
|
||||
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "Die Datei „{0}“ wurde während der Indexierung geändert. Die bereits indexierten Inhalte werden verworfen, und die Datei wird beim nächsten Durchlauf erneut verarbeitet."
|
||||
|
||||
@@ -12861,15 +13263,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T242460
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "Der Ordner „{0}“ konnte nicht geöffnet werden. Bitte prüfen Sie, ob Sie ihn lesen dürfen."
|
||||
|
||||
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "Der Ordner „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem er sich befindet, nicht verbunden, oder der Ordner wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Sobald der Ordner wieder verfügbar ist, aktualisieren Sie diese Datenquelle."
|
||||
|
||||
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "Die Datei „{0}“ konnte nicht gelesen werden. Bitte prüfen Sie, ob Sie berechtigt sind, sie zu lesen."
|
||||
|
||||
-- The file '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T451561215"] = "Die Datei „{0}“ existiert nicht."
|
||||
|
||||
-- The folder '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T871336081"] = "Der Ordner „{0}“ existiert nicht."
|
||||
|
||||
-- The local index '{0}' could not be created again. Please restart AI Studio and try once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::INDEXEDRUNCONTEXT::T1394295123"] = "Der lokale Index „{0}“ konnte nicht erneut erstellt werden. Bitte starten Sie AI Studio neu und versuchen Sie es noch einmal."
|
||||
|
||||
|
||||
+408
-9
@@ -3774,6 +3774,237 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "C
|
||||
-- Configure Keyboard Shortcut
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
|
||||
|
||||
-- Keep the existing chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1274903302"] = "Keep the existing chat"
|
||||
|
||||
-- The backup goes on when you leave this page.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1298413158"] = "The backup goes on when you leave this page."
|
||||
|
||||
-- Unpacking file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1496386567"] = "Unpacking file {0} of {1}"
|
||||
|
||||
-- Created on {0} with AI Studio {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1503285113"] = "Created on {0} with AI Studio {1}"
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1555678613"] = "AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first."
|
||||
|
||||
-- Replace All Data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1670691112"] = "Replace All Data"
|
||||
|
||||
-- Reading the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1731783566"] = "Reading the backup"
|
||||
|
||||
-- Please stay on this page until this is done. Leaving the page stops adding the chats.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1758163038"] = "Please stay on this page until this is done. Leaving the page stops adding the chats."
|
||||
|
||||
-- Back up
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T1823019579"] = "Back up"
|
||||
|
||||
-- When a chat already exists
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2117204104"] = "When a chat already exists"
|
||||
|
||||
-- Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2176422849"] = "Restoring this backup replaces your settings, your chats, your plugins, and the indexes of your data sources with those of the backup. Whatever you changed since the backup was made is lost. Afterward, AI Studio restarts. Do you want to restore this backup?"
|
||||
|
||||
-- 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2301695346"] = "1 chat"
|
||||
|
||||
-- Writing file {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2416389933"] = "Writing file {0} of {1}"
|
||||
|
||||
-- Temporary chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2532369939"] = "Temporary chats"
|
||||
|
||||
-- Adding the chats was stopped. The chats added until then were kept.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2586532370"] = "Adding the chats was stopped. The chats added until then were kept."
|
||||
|
||||
-- Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T267943793"] = "Restore"
|
||||
|
||||
-- Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T2693525709"] = "Replaces your settings, chats, plugins, and the indexes of your data sources with those of the backup. AI Studio restarts to do so. The plugins and settings of your organization stay as they are."
|
||||
|
||||
-- Chats which already existed and stayed as they are: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T29276052"] = "Chats which already existed and stayed as they are: {0}."
|
||||
|
||||
-- The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3006171489"] = "The indexes of your data sources are not part of this backup, because they were not available. After a restore, AI Studio indexes your data sources anew."
|
||||
|
||||
-- Preparing the backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3082169282"] = "Preparing the backup"
|
||||
|
||||
-- Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3087579229"] = "Back up all your data into one file: your settings, chats, plugins, and the indexes of your data sources. Your API keys and passwords stay out of it. AI Studio keeps them in the secure storage of your OS."
|
||||
|
||||
-- Choose backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3114985646"] = "Choose backup"
|
||||
|
||||
-- Select a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T311951952"] = "Select a backup"
|
||||
|
||||
-- Add chats only
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3238328507"] = "Add chats only"
|
||||
|
||||
-- Chat {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T355446621"] = "Chat {0} of {1}"
|
||||
|
||||
-- {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3562573794"] = "{0} chats"
|
||||
|
||||
-- Add {0} chats
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3585479613"] = "Add {0} chats"
|
||||
|
||||
-- Backed up {0} files into: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3630348902"] = "Backed up {0} files into: {1}"
|
||||
|
||||
-- Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3657899737"] = "Restore a backup: replace all your data with those of the backup, e.g., on a new computer, or add only its chats to the chats you have."
|
||||
|
||||
-- Add it as an additional chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3966117123"] = "Add it as an additional chat"
|
||||
|
||||
-- Chats added: {0}. Workspaces they went into: {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T3972560517"] = "Chats added: {0}. Workspaces they went into: {1}."
|
||||
|
||||
-- The backup was stopped. No file was left behind.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T431814261"] = "The backup was stopped. No file was left behind."
|
||||
|
||||
-- Adds the chats of the backup to the chats you have. Nothing else changes.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T457177394"] = "Adds the chats of the backup to the chats you have. Nothing else changes."
|
||||
|
||||
-- Chats which could not be added: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T611768714"] = "Chats which could not be added: {0}."
|
||||
|
||||
-- The restore was stopped. All your data stay as they are.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T749188343"] = "The restore was stopped. All your data stay as they are."
|
||||
|
||||
-- Back up data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T796613169"] = "Back up data"
|
||||
|
||||
-- Replace all data
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T819832488"] = "Replace all data"
|
||||
|
||||
-- Cancel
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T900713019"] = "Cancel"
|
||||
|
||||
-- Copying the indexes of your data sources: {0} of {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T907141309"] = "Copying the indexes of your data sources: {0} of {1}"
|
||||
|
||||
-- Add 1 chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATABACKUPPANEL::T938996955"] = "Add 1 chat"
|
||||
|
||||
-- Folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T104168189"] = "Folder"
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1093747001"] = "Reason"
|
||||
|
||||
-- Configuration folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1191404447"] = "Configuration folder"
|
||||
|
||||
-- Could not open the folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1215994612"] = "Could not open the folder."
|
||||
|
||||
-- Your projects in the Visual Briefing Assistant.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Your projects in the Visual Briefing Assistant."
|
||||
|
||||
-- Your temporary chats, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Your temporary chats, along with their transcripts."
|
||||
|
||||
-- What belongs into a backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "What belongs into a backup"
|
||||
|
||||
-- The index of your local data sources: which files AI Studio embedded, and their text.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T170332953"] = "The index of your local data sources: which files AI Studio embedded, and their text."
|
||||
|
||||
-- Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1981169629"] = "Keys with which AI Studio protects data in its user interface. Only the Flatpak keeps them here, and on another computer, AI Studio creates new ones."
|
||||
|
||||
-- Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1992121437"] = "Tools which AI Studio downloaded for this OS, such as Pandoc. On another computer, AI Studio downloads them again."
|
||||
|
||||
-- What a backup can leave out
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2120934651"] = "What a backup can leave out"
|
||||
|
||||
-- Good to know
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2373663376"] = "Good to know"
|
||||
|
||||
-- Data folder
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2422278827"] = "Data folder"
|
||||
|
||||
-- Explanation
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2840582448"] = "Explanation"
|
||||
|
||||
-- Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2851981238"] = "Copies of your settings, which every version of AI Studio keeps for itself. Your current settings are all a backup needs."
|
||||
|
||||
-- The tokenizers you chose for your providers.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2892362083"] = "The tokenizers you chose for your providers."
|
||||
|
||||
-- To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2906063884"] = "To back up your data with AI Studio itself, or to move it to another computer, use Data Backup & Restore on this page or in the settings instead."
|
||||
|
||||
-- Your plugins, such as the assistants you built, apart from the folders listed below.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2979110360"] = "Your plugins, such as the assistants you built, apart from the folders listed below."
|
||||
|
||||
-- Open in your file manager
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T2986518596"] = "Open in your file manager"
|
||||
|
||||
-- Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3156743605"] = "Your organization deployed some plugins among your own ones, for example, with a device management. Their folders do not show this, so the list above names them one by one."
|
||||
|
||||
-- Content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3244793698"] = "Content"
|
||||
|
||||
-- The embeddings of your local data sources.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3301102729"] = "The embeddings of your local data sources."
|
||||
|
||||
-- The folder is not known yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3310368654"] = "The folder is not known yet."
|
||||
|
||||
-- Could not open the folder: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3380661810"] = "Could not open the folder: {0}"
|
||||
|
||||
-- Unknown error
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3461425987"] = "Unknown error"
|
||||
|
||||
-- Plugins of your organization. Your organization brings them back.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T346886000"] = "Plugins of your organization. Your organization brings them back."
|
||||
|
||||
-- Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3655837348"] = "Back up the indexes of your data sources only while AI Studio is closed. AI Studio writes to them while it runs, so a copy made meanwhile may not fit together, and AI Studio would have to embed your data sources anew."
|
||||
|
||||
-- Files which AI Studio needs only for a moment and deletes again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T366353671"] = "Files which AI Studio needs only for a moment and deletes again."
|
||||
|
||||
-- Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3877928356"] = "Plugins which come with AI Studio, such as the language packs. AI Studio brings them back by itself."
|
||||
|
||||
-- Path
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T3949388886"] = "Path"
|
||||
|
||||
-- Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T4093107108"] = "Your API keys, tokens, and passwords are not in these folders. AI Studio keeps them in the secure storage of your OS. After you restored your data on another computer, enter them there once more."
|
||||
|
||||
-- Your chats in workspaces, along with their transcripts.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T509585668"] = "Your chats in workspaces, along with their transcripts."
|
||||
|
||||
-- Log files. They help when something goes wrong, but they hold none of your data.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T53143530"] = "Log files. They help when something goes wrong, but they hold none of your data."
|
||||
|
||||
-- AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T69622543"] = "AI Studio keeps your data in two folders on this computer. When you back up your computer with a tool of your own, such as [Restic](https://restic.net), [Borg](https://www.borgbackup.org), or [Time Machine](https://support.apple.com/en-us/104984), this shows which of their contents belong into the backup and which it can leave out. Click a path to copy it to the clipboard, or use the folder button to open it in your file manager."
|
||||
|
||||
-- Your settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T790727583"] = "Your settings."
|
||||
|
||||
-- The following is meant for experts who back up their computer with a tool of their own.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "The following is meant for experts who back up their computer with a tool of their own."
|
||||
|
||||
-- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along."
|
||||
|
||||
-- Yes, please send my data to the external embedding provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Yes, please send my data to the external embedding provider"
|
||||
|
||||
@@ -3810,6 +4041,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T6617545
|
||||
-- All files in this folder and its subfolders
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T916879200"] = "All files in this folder and its subfolders"
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1006756723"] = "AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."
|
||||
|
||||
-- You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "You might configure different data sources. A data source can include one file, all files in a directory, or data from your company. Later, you can incorporate these data sources as needed when the AI requires this data to complete a certain task."
|
||||
|
||||
@@ -6087,6 +6321,84 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T1642511898"] = "No"
|
||||
-- Yes
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIRMDIALOG::T3013883440"] = "Yes"
|
||||
|
||||
-- Restart now
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T1409292924"] = "Restart now"
|
||||
|
||||
-- Discard the restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T3017406920"] = "Discard the restore"
|
||||
|
||||
-- Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T495853773"] = "Your backup is ready. AI Studio restarts to put it in place. Whatever you changed until then would be lost, so please restart now."
|
||||
|
||||
-- AI Studio could not restart by itself. Please quit AI Studio and start it again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREPENDINGDIALOG::T573887863"] = "AI Studio could not restart by itself. Please quit AI Studio and start it again."
|
||||
|
||||
-- Restoring your backup failed. AI Studio put all your data back the way they were before.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1057857006"] = "Restoring your backup failed. AI Studio put all your data back the way they were before."
|
||||
|
||||
-- Your backup was not restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1193456690"] = "Your backup was not restored"
|
||||
|
||||
-- Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T123391329"] = "Restoring your backup failed, and AI Studio could not yet put all your data back the way they were. It tries again at the next start, so please restart AI Studio. If this message appears again, please contact the support, and keep the folders '.restore-previous' and '.restore-pending' in your data folder."
|
||||
|
||||
-- The batch processing uses '{0}', which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1314976065"] = "The batch processing uses '{0}', which does not exist on this computer."
|
||||
|
||||
-- The backup is from {0} and was made with AI Studio {1}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1363238678"] = "The backup is from {0} and was made with AI Studio {1}."
|
||||
|
||||
-- A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1633897333"] = "A backup holds no API keys or passwords, and some files and folders are not on this computer. Please check the following:"
|
||||
|
||||
-- Enter the API key of the provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T1645175373"] = "Enter the API key of the provider '{0}' again, if it needs one."
|
||||
|
||||
-- Restoring your backup is not finished
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2415452768"] = "Restoring your backup is not finished"
|
||||
|
||||
-- Enter the API key of the embedding provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2492749778"] = "Enter the API key of the embedding provider '{0}' again, if it needs one."
|
||||
|
||||
-- Enter the API key of the transcription provider '{0}' again, if it needs one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2568556297"] = "Enter the API key of the transcription provider '{0}' again, if it needs one."
|
||||
|
||||
-- Nothing else to do: every provider and every data source has what it needs on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2671436044"] = "Nothing else to do: every provider and every data source has what it needs on this computer."
|
||||
|
||||
-- The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T270687216"] = "The data source '{0}' names a file or folder which does not exist on this computer. Choose it anew."
|
||||
|
||||
-- The file with your own root certificates, '{0}', does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T2844911500"] = "The file with your own root certificates, '{0}', does not exist on this computer."
|
||||
|
||||
-- AI Studio could not start restoring your backup, so all your data stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3044053464"] = "AI Studio could not start restoring your backup, so all your data stayed as they were."
|
||||
|
||||
-- Enter the password of the mailbox '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3083231765"] = "Enter the password of the mailbox '{0}' again."
|
||||
|
||||
-- Enter the password or token of the data source '{0}' again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3430781520"] = "Enter the password or token of the data source '{0}' again."
|
||||
|
||||
-- Technical details: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3436390766"] = "Technical details: {0}"
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3448155331"] = "Close"
|
||||
|
||||
-- Enter the setting '{1}' of the tool '{0}' again, if you used it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3658964195"] = "Enter the setting '{1}' of the tool '{0}' again, if you used it."
|
||||
|
||||
-- AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3896631626"] = "AI Studio replaced your data with those of the backup. The plugins and settings of your organization stayed as they were."
|
||||
|
||||
-- Your backup was restored
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Your backup was restored"
|
||||
|
||||
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "The ERI server '{0}' writes into a folder which does not exist on this computer."
|
||||
|
||||
-- How many matches do you want at most per query?
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "How many matches do you want at most per query?"
|
||||
|
||||
@@ -9387,6 +9699,9 @@ UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2800137365"] = "An update to ver
|
||||
-- Please wait for the update to complete...
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2864211629"] = "Please wait for the update to complete..."
|
||||
|
||||
-- Restore of a Backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2912921801"] = "Restore of a Backup"
|
||||
|
||||
-- Supporters
|
||||
UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2929332068"] = "Supporters"
|
||||
|
||||
@@ -10191,6 +10506,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3294830584"] = "Discover MindWor
|
||||
-- External HTTPS custom root certificates
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "External HTTPS custom root certificates"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3317700277"] = "Data Backup & Restore"
|
||||
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "User-language provided by the OS"
|
||||
|
||||
@@ -10347,6 +10665,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T551035563"] = "Open Log Viewer"
|
||||
-- This is a library providing the foundations for asynchronous programming in Rust. It includes key trait definitions like Stream, as well as utilities like join!, select!, and various futures combinator methods which enable expressive asynchronous control flow.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library providing the foundations for asynchronous programming in Rust. It includes key trait definitions like Stream, as well as utilities like join!, select!, and various futures combinator methods which enable expressive asynchronous control flow."
|
||||
|
||||
-- Your Data Folders
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T570573187"] = "Your Data Folders"
|
||||
|
||||
-- Used .NET SDK
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK"
|
||||
|
||||
@@ -10530,15 +10851,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "The plugin could not
|
||||
-- Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings"
|
||||
|
||||
-- Data Backup & Restore
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T3317700277"] = "Data Backup & Restore"
|
||||
|
||||
-- Thank you very much, Kerstin, for creating the Wiki, for advising us on ERI with real use cases, and for your work on version 2 of the ERI protocol.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1367356224"] = "Thank you very much, Kerstin, for creating the Wiki, for advising us on ERI with real use cases, and for your work on version 2 of the ERI protocol."
|
||||
|
||||
-- Thank you for being the first to contribute a one-time donation.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1470916504"] = "Thank you for being the first to contribute a one-time donation."
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1701321325"] = "Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, and everything else you are building for AI Studio with so much dedication."
|
||||
|
||||
-- Thank you, Peer, for your courage in being the second person to support the project financially.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T1714878838"] = "Thank you, Peer, for your courage in being the second person to support the project financially."
|
||||
|
||||
@@ -10626,6 +10947,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T686206269"] = "Thanks for your bu
|
||||
-- Business Contributors
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T838479287"] = "Business Contributors"
|
||||
|
||||
-- Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T882777973"] = "Thank you, Jens, for the Batch Processing Assistant, the exact token count in the chat, the backups of all data, and everything else you are building for AI Studio with so much dedication."
|
||||
|
||||
-- Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T916186976"] = "Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI."
|
||||
|
||||
@@ -11268,6 +11592,15 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T810547195"] = "No au
|
||||
-- Username & Password
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T863358904"] = "Username & Password"
|
||||
|
||||
-- Unexpected error: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T2350673880"] = "Unexpected error: {0}"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- Imported workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CHATARCHIVE::CHATARCHIVEIMPORTER::T722364324"] = "Imported workspace"
|
||||
|
||||
-- Spanish (Spain)
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMMONLANGUAGEEXTENSIONS::T1269145091"] = "Spanish (Spain)"
|
||||
|
||||
@@ -11403,6 +11736,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = "
|
||||
-- Trust all LLM providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers"
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3812849494"] = "This backup was made by a newer version of AI Studio. Please update AI Studio first."
|
||||
|
||||
-- The content list of this backup could not be read.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATABACKUPREADER::T3954689017"] = "The content list of this backup could not be read."
|
||||
|
||||
-- This file is no backup of AI Studio, or it was not written completely.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3123318432"] = "This file is no backup of AI Studio, or it was not written completely."
|
||||
|
||||
-- The backup is damaged: the file '{0}' does not match its content list.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3493230602"] = "The backup is damaged: the file '{0}' does not match its content list."
|
||||
|
||||
-- This backup was made by a newer version of AI Studio. Please update AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABACKUP::DATARESTOREPREPARER::T3812849494"] = "This backup was made by a newer version of AI Studio. Please update AI Studio first."
|
||||
|
||||
-- Reason
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1093747001"] = "Reason"
|
||||
|
||||
@@ -11553,6 +11904,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T858000918"] = "Thes
|
||||
-- Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T874850580"] = "Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while."
|
||||
|
||||
-- AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T1006756723"] = "AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."
|
||||
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T3448155331"] = "Close"
|
||||
|
||||
-- Repair Data Source
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T4175865785"] = "Repair Data Source"
|
||||
|
||||
@@ -12483,6 +12840,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T377
|
||||
-- Text
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
|
||||
|
||||
-- AI Studio backup
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1046878067"] = "AI Studio backup"
|
||||
|
||||
-- Office Files
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
|
||||
|
||||
@@ -12744,9 +13104,45 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T5
|
||||
-- Workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T658612054"] = "Workspace"
|
||||
|
||||
-- A backup is running already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1319749333"] = "A backup is running already."
|
||||
|
||||
-- The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1869616367"] = "The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive."
|
||||
|
||||
-- The backup failed: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T1915001713"] = "The backup failed: {0}"
|
||||
|
||||
-- Your organization does not allow backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T2011318013"] = "Your organization does not allow backups."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please start the backup once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATABACKUPSERVICE::T4161531565"] = "AI Studio is still writing an answer or a transcript. Please start the backup once it is done."
|
||||
|
||||
-- Your organization does not allow restoring backups.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1032491606"] = "Your organization does not allow restoring backups."
|
||||
|
||||
-- AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1555678613"] = "AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first."
|
||||
|
||||
-- AI Studio is still writing an answer or a transcript. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T1847516630"] = "AI Studio is still writing an answer or a transcript. Please restore once it is done."
|
||||
|
||||
-- The restore could not be prepared: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T2653345012"] = "The restore could not be prepared: {0}"
|
||||
|
||||
-- A restore is being prepared already.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T4157700208"] = "A restore is being prepared already."
|
||||
|
||||
-- A backup is running. Please restore once it is done.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATARESTORESERVICE::T553866901"] = "A backup is running. Please restore once it is done."
|
||||
|
||||
-- The local RAG index database is not available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T1738200026"] = "The local RAG index database is not available."
|
||||
|
||||
-- The index of the data source '{0}' is missing. The data source stays out of your chats until its index was built anew. Use the repair action to start that.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T204783307"] = "The index of the data source '{0}' is missing. The data source stays out of your chats until its index was built anew. Use the repair action to start that."
|
||||
|
||||
-- The selected embedding provider is not allowed to index this data source. The data source asks for the confidence level '{0}', while the embedding provider has '{1}'.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T2186533187"] = "The selected embedding provider is not allowed to index this data source. The data source asks for the confidence level '{0}', while the embedding provider has '{1}'."
|
||||
|
||||
@@ -12852,6 +13248,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T2360
|
||||
-- The mail '{0}' could not be indexed. AI Studio tries again during the next sync.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T804280374"] = "The mail '{0}' could not be indexed. AI Studio tries again during the next sync."
|
||||
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."
|
||||
|
||||
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
|
||||
|
||||
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."
|
||||
|
||||
@@ -12861,15 +13263,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T242460
|
||||
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it."
|
||||
|
||||
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source."
|
||||
|
||||
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "The file '{0}' could not be read. Please check whether you are allowed to read it."
|
||||
|
||||
-- The file '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T451561215"] = "The file '{0}' does not exist."
|
||||
|
||||
-- The folder '{0}' does not exist.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T871336081"] = "The folder '{0}' does not exist."
|
||||
|
||||
-- The local index '{0}' could not be created again. Please restart AI Studio and try once more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::INDEXEDRUNCONTEXT::T1394295123"] = "The local index '{0}' could not be created again. Please restart AI Studio and try once more."
|
||||
|
||||
|
||||
@@ -210,6 +210,8 @@ internal sealed class Program
|
||||
builder.Services.AddSingleton<DataSourceService>();
|
||||
builder.Services.AddSingleton<DataSourceDescriptionService>();
|
||||
builder.Services.AddSingleton<DataSourceEmbeddingService>();
|
||||
builder.Services.AddSingleton<DataBackupService>();
|
||||
builder.Services.AddSingleton<DataRestoreService>();
|
||||
builder.Services.AddSingleton<LocalIndexSearchService>();
|
||||
builder.Services.AddSingleton<DataSourceLocalRetrievalService>();
|
||||
builder.Services.AddSingleton<MailboxRetrievalService>();
|
||||
@@ -226,6 +228,11 @@ internal sealed class Program
|
||||
builder.Services.AddHostedService<UpdateService>();
|
||||
builder.Services.AddHostedService<TemporaryChatService>();
|
||||
builder.Services.AddHostedService<TranscriptStagingCleanupService>();
|
||||
builder.Services.AddHostedService<DataRestoreFollowUpService>();
|
||||
|
||||
// Compatibility shim, see documentation/compatibility-shims/2026-10-relative-data-paths.md. Remove after 2027-04-14:
|
||||
builder.Services.AddHostedService<RelativeDataPathsMigrationService>();
|
||||
|
||||
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<DataSourceEmbeddingService>());
|
||||
builder.Services.AddSingleton<DatabaseClientProvider>();
|
||||
|
||||
@@ -421,11 +421,7 @@ public record ChatTemplate(
|
||||
}
|
||||
|
||||
var combinedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
|
||||
var pluginRootWithSeparator = pluginRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||
? pluginRoot
|
||||
: pluginRoot + Path.DirectorySeparatorChar;
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
if (!combinedPath.StartsWith(pluginRootWithSeparator, comparison))
|
||||
if (!PathTools.IsInsideDirectory(pluginRoot, combinedPath))
|
||||
{
|
||||
LOGGER.LogWarning("The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} points outside of the plugin folder and will be ignored.", attachmentNum, idx);
|
||||
return false;
|
||||
|
||||
@@ -116,6 +116,16 @@ public sealed class Data
|
||||
/// </remarks>
|
||||
public List<string> AppliedEnterpriseAssistantActivations { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether AI Studio already stored the chats and the settings once more, so that every path below
|
||||
/// the data directory in them is relative.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compatibility shim, see documentation/compatibility-shims/2026-10-relative-data-paths.md.
|
||||
/// Remove after 2027-04-14.
|
||||
/// </remarks>
|
||||
public bool StoredDataPathsAreRelative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The next provider number to use.
|
||||
/// </summary>
|
||||
|
||||
@@ -219,6 +219,24 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
|
||||
/// </summary>
|
||||
public bool AllowUserToSharePlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToSharePlugins, true);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to back up the data of AI Studio into a file?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backup holds chats and other confidential content in a single file, which can leave the
|
||||
/// computer on any stick or cloud drive. Secrets never go into it.
|
||||
/// </remarks>
|
||||
public bool AllowUserToBackUpData { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToBackUpData, true);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to restore the data of AI Studio from a backup file, or to add the chats of one?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backup brings along everything its chats and settings name, including the locations of
|
||||
/// attached documents and of local data sources, which are read again afterward.
|
||||
/// </remarks>
|
||||
public bool AllowUserToRestoreData { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToRestoreData, true);
|
||||
|
||||
/// <summary>
|
||||
/// Should administration settings be visible in the UI?
|
||||
/// </summary>
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed record EmbeddingProvider(
|
||||
Guid EnterpriseConfigurationPluginId = default,
|
||||
string Hostname = "http://localhost:1234",
|
||||
Host Host = Host.NONE,
|
||||
string TokenizerPath = "",
|
||||
[property: JsonConverter(typeof(DataDirectoryRelativePathConverter))] string TokenizerPath = "",
|
||||
string TokenizerFingerprint = "",
|
||||
int EmbeddingBatchSize = 0,
|
||||
int TokenLimit = 0,
|
||||
|
||||
@@ -56,7 +56,7 @@ internal static class PluginIconFile
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsInsideDirectory(pluginRoot, resolvedPath))
|
||||
if (!PathTools.IsInsideDirectory(pluginRoot, resolvedPath))
|
||||
{
|
||||
issue = "The icon path points outside of the configuration plugin directory.";
|
||||
return false;
|
||||
@@ -108,18 +108,10 @@ internal static class PluginIconFile
|
||||
currentPath = Path.Combine(currentPath, segment);
|
||||
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
|
||||
var finalTarget = pathInfo.ResolveLinkTarget(true);
|
||||
if (finalTarget is not null && !IsInsideDirectory(rootDirectory, finalTarget.FullName))
|
||||
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsInsideDirectory(string rootDirectory, string path)
|
||||
{
|
||||
var root = Path.GetFullPath(rootDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var target = Path.GetFullPath(path);
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
return target.StartsWith(root, comparison);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public sealed record Provider(
|
||||
Host Host = Host.NONE,
|
||||
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE,
|
||||
string AdditionalJsonApiParameters = "",
|
||||
string TokenizerPath = "",
|
||||
[property: JsonConverter(typeof(DataDirectoryRelativePathConverter))] string TokenizerPath = "",
|
||||
ProviderCapabilityOverrides? CapabilityOverrides = null,
|
||||
bool AllowUserProvidedAPIKey = false,
|
||||
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
|
||||
|
||||
@@ -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,33 @@
|
||||
using AIStudio.Tools.DataBackup;
|
||||
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// Where a backup keeps its chats, for adding them to the chats there are.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backup holds the chats in the layout of the data folder, cf. DataBackupFormat: every chat in a
|
||||
/// directory of its own, below its workspace or among the temporary chats.
|
||||
/// </remarks>
|
||||
public static class ChatArchiveFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Where a backup keeps the workspaces, each in a directory named after its ID.
|
||||
/// </summary>
|
||||
public static readonly string WORKSPACES_PREFIX = DataBackupFormat.DATA_ROOT + DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.WORKSPACES).RelativePath;
|
||||
|
||||
/// <summary>
|
||||
/// Where a backup keeps the temporary chats, each in a directory named after its ID.
|
||||
/// </summary>
|
||||
public static readonly string TEMPORARY_CHATS_PREFIX = DataBackupFormat.DATA_ROOT + DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.TEMPORARY_CHATS).RelativePath;
|
||||
|
||||
/// <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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,539 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the chats of a backup to the chats there are, and leaves everything else alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other way to use a backup, next to restoring it: a restore replaces all data, while this keeps
|
||||
/// every chat and adds those of the backup. A chat which exists already is kept, or added once more,
|
||||
/// as the user decides.<br/><br/>
|
||||
///
|
||||
/// The backup is opened for reading only, and every entry is read as a stream, cf. DataBackupReader.
|
||||
/// </remarks>
|
||||
public static class ChatArchiveImporter
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(ChatArchiveImporter));
|
||||
|
||||
private const int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatArchiveImporter).Namespace, nameof(ChatArchiveImporter));
|
||||
|
||||
/// <summary>
|
||||
/// One entry of a backup, with its name checked and normalized.
|
||||
/// </summary>
|
||||
private sealed record BackupEntry(string Name, ZipArchiveEntry Entry);
|
||||
|
||||
/// <summary>
|
||||
/// What one import did so far.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every counter is raised as the import goes: a cancellation leaves the chats imported so far in
|
||||
/// the storage, so the numbers have to describe them even when the import never reaches its end.
|
||||
/// </remarks>
|
||||
private sealed class ImportTally
|
||||
{
|
||||
public int ProcessedChats;
|
||||
public int ImportedWorkspaces;
|
||||
public int ImportedChats;
|
||||
public int SkippedChats;
|
||||
public int FailedChats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads which chats a backup holds, without adding any.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The backup to look into.</param>
|
||||
/// <param name="token">Cancels the reading.</param>
|
||||
/// <returns>The chats of the backup, or why the backup cannot be used.</returns>
|
||||
public static async Task<ChatArchiveImportPreview> ReadPreviewAsync(string archivePath, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
||||
if (manifestResult.Manifest is not { } manifest)
|
||||
return new(false, new(), manifestResult.Issue);
|
||||
|
||||
var chats = await ListChatsAsync(archive, GroupEntriesByChat(archive), token);
|
||||
return new(true, chats with { CreatedAt = manifest.CreatedAtUtc, AppVersion = manifest.AppVersion }, string.Empty);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return new(false, new(), TB("This file is no backup of AI Studio, or it was not written completely."));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogError(exception, "Failed to read the backup '{ArchivePath}'.", archivePath);
|
||||
return new(false, new(), string.Format(TB("Unexpected error: {0}"), exception.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all chats of a backup to the chat storage.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The backup.</param>
|
||||
/// <param name="collisionBehavior">What to do with chats which exist already.</param>
|
||||
/// <param name="progress">Receives the progress.</param>
|
||||
/// <param name="token">Cancels the import. Chats added before the cancellation are kept.</param>
|
||||
/// <returns>What the import did.</returns>
|
||||
public static async Task<ChatArchiveImportResult> ImportAsync(string archivePath, ChatArchiveCollisionBehavior collisionBehavior, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var tally = new ImportTally();
|
||||
var preview = await ReadPreviewAsync(archivePath, token);
|
||||
if (!preview.Success)
|
||||
return new(false, 0, 0, 0, 0, preview.Issue, false);
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = preview.Manifest;
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
var entriesByChat = GroupEntriesByChat(archive);
|
||||
|
||||
// Which workspaces exist already is asked once per workspace of the backup, 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 backup
|
||||
// names: it may have been moved since the backup was made. Adding 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);
|
||||
}
|
||||
|
||||
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 backup, because no target workspace could be prepared.", workspace.Name);
|
||||
tally.FailedChats += workspace.Chats.Count;
|
||||
tally.ProcessedChats += workspace.Chats.Count;
|
||||
continue;
|
||||
}
|
||||
|
||||
var workspaceEntryPrefix = $"{ChatArchiveFormat.WORKSPACES_PREFIX}{workspace.WorkspaceId}{ZipEntryNames.SEPARATOR}";
|
||||
await ImportChatsAsync(tally, workspaceEntryPrefix, targetWorkspaceId, workspace.Chats, true, manifest.TotalChatCount, entriesByChat, collisionBehavior, existingChatIds, progress, token);
|
||||
}
|
||||
|
||||
await ImportChatsAsync(tally, ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, Guid.Empty, manifest.TemporaryChats, false, manifest.TotalChatCount, entriesByChat, collisionBehavior, existingChatIds, progress, token);
|
||||
|
||||
progress?.Report(new(tally.ProcessedChats, manifest.TotalChatCount));
|
||||
LOG.LogInformation("Imported {ImportedChats} chats from the backup '{ArchivePath}'. Skipped {SkippedChats} existing chats, {FailedChats} chats failed.", tally.ImportedChats, archivePath, tally.SkippedChats, tally.FailedChats);
|
||||
|
||||
return new(true, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Empty, false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//
|
||||
// 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 backup '{ArchivePath}' after {ImportedChats} chats.", archivePath, tally.ImportedChats);
|
||||
return new(true, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Empty, true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LOG.LogError(exception, "Failed to import the backup '{ArchivePath}'.", archivePath);
|
||||
return new(false, tally.ImportedWorkspaces, tally.ImportedChats, tally.SkippedChats, tally.FailedChats, string.Format(TB("Unexpected error: {0}"), exception.Message), false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the chats of one workspace of the backup, or its temporary chats.
|
||||
/// </summary>
|
||||
private static async Task ImportChatsAsync(ImportTally tally, string entryPrefix, Guid targetWorkspaceId, IReadOnlyList<ChatArchiveManifestChat> chats, bool countsAsWorkspace, int totalChats, Dictionary<string, List<BackupEntry>> entriesByChat, ChatArchiveCollisionBehavior collisionBehavior, HashSet<Guid> existingChatIds, IProgress<ChatArchiveProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var isFirstImportOfWorkspace = true;
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
progress?.Report(new(tally.ProcessedChats, totalChats));
|
||||
tally.ProcessedChats++;
|
||||
|
||||
var chatEntryPrefix = $"{entryPrefix}{chat.ChatId}{ZipEntryNames.SEPARATOR}";
|
||||
var chatEntries = entriesByChat.GetValueOrDefault(chatEntryPrefix, []);
|
||||
|
||||
switch (await ImportChatAsync(chatEntries, chatEntryPrefix, targetWorkspaceId, chat, collisionBehavior, existingChatIds, token))
|
||||
{
|
||||
case ChatArchiveImportOutcome.IMPORTED:
|
||||
tally.ImportedChats++;
|
||||
if (countsAsWorkspace && isFirstImportOfWorkspace)
|
||||
{
|
||||
tally.ImportedWorkspaces++;
|
||||
isFirstImportOfWorkspace = false;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ChatArchiveImportOutcome.SKIPPED:
|
||||
tally.SkippedChats++;
|
||||
break;
|
||||
|
||||
case ChatArchiveImportOutcome.FAILED:
|
||||
tally.FailedChats++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the entries of a backup by the chat they belong to. Without this, every chat would have
|
||||
/// to scan all entries of the backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An entry whose name could lead outside of the chat directory is left out, cf. ZipEntryNames.
|
||||
/// </remarks>
|
||||
private static Dictionary<string, List<BackupEntry>> GroupEntriesByChat(ZipArchive archive)
|
||||
{
|
||||
var entriesByChat = new Dictionary<string, List<BackupEntry>>(StringComparer.Ordinal);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!ZipEntryNames.TryNormalize(entry.FullName, out var entryName))
|
||||
{
|
||||
LOG.LogWarning("Ignoring the backup entry '{EntryName}', because its name could lead outside of the chat directory.", entry.FullName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip directory entries:
|
||||
if (entryName.EndsWith(ZipEntryNames.SEPARATOR))
|
||||
continue;
|
||||
|
||||
var chatEntryPrefix = GetChatEntryPrefix(entryName);
|
||||
if (chatEntryPrefix is null)
|
||||
continue;
|
||||
|
||||
if (!entriesByChat.TryGetValue(chatEntryPrefix, out var chatEntries))
|
||||
{
|
||||
chatEntries = [];
|
||||
entriesByChat[chatEntryPrefix] = chatEntries;
|
||||
}
|
||||
|
||||
chatEntries.Add(new(entryName, entry));
|
||||
}
|
||||
|
||||
return entriesByChat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the chat an entry belongs to: everything up to and including the ID of the chat, so
|
||||
/// "data/workspaces/W/C/thread.json" becomes "data/workspaces/W/C/".
|
||||
/// </summary>
|
||||
/// <returns>The chat prefix, or null when the entry belongs to no chat.</returns>
|
||||
private static string? GetChatEntryPrefix(string entryName)
|
||||
{
|
||||
string root;
|
||||
int levels;
|
||||
if (entryName.StartsWith(ChatArchiveFormat.WORKSPACES_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
root = ChatArchiveFormat.WORKSPACES_PREFIX;
|
||||
levels = 2;
|
||||
}
|
||||
else if (entryName.StartsWith(ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
root = ChatArchiveFormat.TEMPORARY_CHATS_PREFIX;
|
||||
levels = 1;
|
||||
}
|
||||
else
|
||||
return null;
|
||||
|
||||
var index = root.Length;
|
||||
for (var level = 0; level < levels; level++)
|
||||
{
|
||||
var next = entryName.IndexOf(ZipEntryNames.SEPARATOR, index);
|
||||
if (next < 0)
|
||||
return null;
|
||||
|
||||
index = next + 1;
|
||||
}
|
||||
|
||||
return entryName[..index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the chats of a backup, with the names of their workspaces.
|
||||
/// </summary>
|
||||
private static async Task<ChatArchiveManifest> ListChatsAsync(ZipArchive archive, Dictionary<string, List<BackupEntry>> entriesByChat, CancellationToken token)
|
||||
{
|
||||
var workspaces = new Dictionary<Guid, ChatArchiveManifestWorkspace>();
|
||||
var temporaryChats = new List<ChatArchiveManifestChat>();
|
||||
foreach (var (chatEntryPrefix, chatEntries) in entriesByChat)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// A chat without its thread cannot be added, so it is not listed either:
|
||||
if (!chatEntries.Any(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
var segments = chatEntryPrefix.TrimEnd(ZipEntryNames.SEPARATOR).Split(ZipEntryNames.SEPARATOR);
|
||||
if (!Guid.TryParse(segments[^1], out var chatId))
|
||||
continue;
|
||||
|
||||
var nameEntry = chatEntries.FirstOrDefault(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.NAME_FILE_NAME}", StringComparison.OrdinalIgnoreCase));
|
||||
var chat = new ChatArchiveManifestChat
|
||||
{
|
||||
ChatId = chatId,
|
||||
Name = nameEntry is null ? string.Empty : await ReadTextAsync(nameEntry.Entry, token),
|
||||
};
|
||||
|
||||
if (chatEntryPrefix.StartsWith(ChatArchiveFormat.TEMPORARY_CHATS_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
temporaryChats.Add(chat);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(segments[^2], out var workspaceId))
|
||||
continue;
|
||||
|
||||
if (!workspaces.TryGetValue(workspaceId, out var workspace))
|
||||
{
|
||||
var workspaceNameEntry = archive.GetEntry($"{ChatArchiveFormat.WORKSPACES_PREFIX}{workspaceId}{ZipEntryNames.SEPARATOR}{ChatArchiveFormat.NAME_FILE_NAME}");
|
||||
workspace = new ChatArchiveManifestWorkspace
|
||||
{
|
||||
WorkspaceId = workspaceId,
|
||||
Name = workspaceNameEntry is null ? string.Empty : await ReadTextAsync(workspaceNameEntry, token),
|
||||
};
|
||||
|
||||
workspaces[workspaceId] = workspace;
|
||||
}
|
||||
|
||||
workspace.Chats.Add(chat);
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Workspaces = workspaces.Values.OrderBy(workspace => workspace.Name, StringComparer.CurrentCultureIgnoreCase).ToList(),
|
||||
TemporaryChats = temporaryChats,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ReadTextAsync(ZipArchiveEntry entry, CancellationToken token)
|
||||
{
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
return (await reader.ReadToEndAsync(token)).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the workspace which receives the chats of a workspace of the backup. Known
|
||||
/// workspaces are reused, unknown ones are created with their original identity.
|
||||
/// </summary>
|
||||
/// <param name="workspace">The workspace of the backup.</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, points the paths of
|
||||
/// those files to it, and stores the chat.
|
||||
/// </summary>
|
||||
/// <param name="chatEntries">The backup entries of the chat.</param>
|
||||
/// <param name="chatEntryPrefix">The path of the chat within the backup, which every entry of the chat starts with.</param>
|
||||
/// <param name="targetWorkspaceId">The workspace to import the chat into, or Guid.Empty for a temporary chat.</param>
|
||||
/// <param name="chat">The chat as the preview lists it.</param>
|
||||
/// <param name="collisionBehavior">What to do when a chat with the same ID exists already.</param>
|
||||
/// <param name="existingChatIds">The IDs of all chats in the storage. An imported chat is added.</param>
|
||||
/// <param name="token">Cancels the import.</param>
|
||||
private static async Task<ChatArchiveImportOutcome> ImportChatAsync(IReadOnlyList<BackupEntry> chatEntries, string chatEntryPrefix, Guid targetWorkspaceId, ChatArchiveManifestChat chat, ChatArchiveCollisionBehavior collisionBehavior, HashSet<Guid> existingChatIds, CancellationToken token)
|
||||
{
|
||||
var threadEntry = chatEntries.FirstOrDefault(entry => entry.Name.Equals($"{chatEntryPrefix}{ChatArchiveFormat.THREAD_FILE_NAME}", StringComparison.OrdinalIgnoreCase));
|
||||
if (threadEntry is null)
|
||||
{
|
||||
LOG.LogWarning("The backup 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 = entry.Name[chatEntryPrefix.Length..];
|
||||
if (relativePath.Equals(ChatArchiveFormat.THREAD_FILE_NAME, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Equals(ChatArchiveFormat.NAME_FILE_NAME, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var destinationPath = ZipEntryNames.GetDestinationPath(chatDirectory, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
await using var entryStream = entry.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.Entry.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;
|
||||
}
|
||||
|
||||
//
|
||||
// A file below the data directory is stored with a path relative to it, which reading
|
||||
// the thread resolved against the data directory of this computer: where the chat lay
|
||||
// when the backup was made. Only the paths of files which were actually extracted move
|
||||
// to the imported chat. Everything else keeps its path:
|
||||
//
|
||||
var originalChatDirectory = WorkspaceBehaviour.GetChatDirectory(thread.WorkspaceId, thread.ChatId);
|
||||
thread = thread with { ChatId = targetChatId };
|
||||
thread.WorkspaceId = targetWorkspaceId;
|
||||
thread.RewriteFilePaths(path =>
|
||||
{
|
||||
var chatRelativePath = Path.GetRelativePath(originalChatDirectory, path).Replace(Path.DirectorySeparatorChar, ZipEntryNames.SEPARATOR);
|
||||
return extractedPaths.Contains(chatRelativePath)
|
||||
? Path.Combine(chatDirectory, chatRelativePath.Replace(ZipEntryNames.SEPARATOR, Path.DirectorySeparatorChar))
|
||||
: path;
|
||||
});
|
||||
|
||||
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 backup did not carry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Such a transcript still names the place where it lay when the backup was made. On the
|
||||
/// same computer that is the chat the backup 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 the paths of its extracted files were pointed to it.</param>
|
||||
/// <param name="chatDirectory">The directory of the imported chat.</param>
|
||||
private static void CopyTranscriptsStoredElsewhere(ChatThread thread, string chatDirectory)
|
||||
{
|
||||
var transcriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory);
|
||||
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 a
|
||||
// backup cannot get files from elsewhere on this computer copied into a chat:
|
||||
//
|
||||
thread.ReplaceAttachments(attachment => attachment is ManagedTranscriptAttachment transcript
|
||||
&& !PathTools.IsInsideDirectory(chatDirectory, transcript.FilePath)
|
||||
&& PathTools.IsInsideDirectory(SettingsManager.DataDirectory!, transcript.FilePath)
|
||||
? WorkspaceBehaviour.CopyManagedTranscriptAttachment(thread, transcript, transcriptDirectory, copiedPaths)
|
||||
: attachment);
|
||||
}
|
||||
|
||||
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,32 @@
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// The chats a backup holds, so that the user sees them before any is added.
|
||||
/// </summary>
|
||||
public sealed record ChatArchiveManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// When the backup was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The AI Studio version which made the backup. Informational only.
|
||||
/// </summary>
|
||||
public string AppVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All workspaces with chats in the backup.
|
||||
/// </summary>
|
||||
public List<ChatArchiveManifestWorkspace> Workspaces { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// All temporary chats in the backup.
|
||||
/// </summary>
|
||||
public List<ChatArchiveManifestChat> TemporaryChats { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of chats in the backup.
|
||||
/// </summary>
|
||||
public int TotalChatCount => this.Workspaces.Sum(workspace => workspace.Chats.Count) + this.TemporaryChats.Count;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// One chat in a backup.
|
||||
/// </summary>
|
||||
public sealed record ChatArchiveManifestChat
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the chat.
|
||||
/// </summary>
|
||||
public Guid ChatId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the chat when the backup was made.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.ChatArchive;
|
||||
|
||||
/// <summary>
|
||||
/// One workspace with chats in a backup.
|
||||
/// </summary>
|
||||
public sealed record ChatArchiveManifestWorkspace
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique identifier of the workspace.
|
||||
/// </summary>
|
||||
public Guid WorkspaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the workspace when the backup was made.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All chats of this workspace in the backup.
|
||||
/// </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);
|
||||
@@ -0,0 +1,119 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What AI Studio keeps in its data and configuration folders, and which of it belongs into a backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a positive list. A backup takes along only the entries marked as included, and restoring
|
||||
/// one replaces only those. Everything else in these folders stays out of a backup and survives a
|
||||
/// restore: the leftovers of a plugin installation, a file somebody else put there, or a folder a
|
||||
/// future version adds before anybody listed it here.<br/><br/>
|
||||
///
|
||||
/// The entries which stay out are listed, too. The folder panel on the information page tells users
|
||||
/// who back up these folders with a tool of their own what they may leave out, and nobody has to guess
|
||||
/// whether a folder was forgotten or left out on purpose. Whoever adds a file or folder to the data or
|
||||
/// configuration folder adds it here, one way or the other.<br/><br/>
|
||||
///
|
||||
/// The runtime, which applies a restore, keeps its own list of the included entries and refuses to
|
||||
/// replace anything else (REPLACEABLE_ELEMENTS in runtime/src/data_restore.rs). An entry included here
|
||||
/// goes there as well.
|
||||
/// </remarks>
|
||||
public static class DataBackupContents
|
||||
{
|
||||
/// <summary>
|
||||
/// The separator of the relative paths, the same on every OS.
|
||||
/// </summary>
|
||||
public const char SEPARATOR = '/';
|
||||
|
||||
/// <summary>
|
||||
/// The folder of the plugins, within the data folder.
|
||||
/// </summary>
|
||||
public const string PLUGINS_FOLDER = "plugins/";
|
||||
|
||||
/// <summary>
|
||||
/// Every file and folder AI Studio keeps directly in its data or configuration folder, and those
|
||||
/// below the folders of the plugins and the databases which need a decision of their own.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<DataBackupEntry> ENTRIES =
|
||||
[
|
||||
new(DataBackupLocation.CONFIG_DIRECTORY, "settings.json", DataBackupEntryKind.SETTINGS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "workspaces/", DataBackupEntryKind.WORKSPACES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "tempChats/", DataBackupEntryKind.TEMPORARY_CHATS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, PLUGINS_FOLDER, DataBackupEntryKind.PLUGINS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "tokenizers/", DataBackupEntryKind.TOKENIZERS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "databases/sqlite/", DataBackupEntryKind.DATA_SOURCE_INDEX),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "databases/vector_database/stores/", DataBackupEntryKind.VECTOR_STORES),
|
||||
|
||||
new(DataBackupLocation.CONFIG_DIRECTORY, "settings.v*.json", DataBackupEntryKind.SETTINGS_OF_OTHER_VERSIONS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "plugins/.internal/", DataBackupEntryKind.BUILT_IN_PLUGINS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "plugins/.config/", DataBackupEntryKind.ORGANIZATION_PLUGINS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "plugins/.config-tests/", DataBackupEntryKind.ORGANIZATION_PLUGINS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "plugins/.lock", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, ".plugin-staging/", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, ".plugin-install-backups/", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, ".plugin-delete-backups/", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "media-staging/", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "mailAttachments/", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "mindwork_ai_studio.pid", DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, DataBackupFormat.STAGING_DIRECTORY, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, DataRestoreFormat.PENDING_DIRECTORY, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.CONFIG_DIRECTORY, DataRestoreFormat.PENDING_DIRECTORY, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, DataRestoreFormat.PREVIOUS_DIRECTORY, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.CONFIG_DIRECTORY, DataRestoreFormat.PREVIOUS_DIRECTORY, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, DataRestoreFormat.RESULT_FILE_NAME, DataBackupEntryKind.TEMPORARY_FILES),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "pandoc/", DataBackupEntryKind.DOWNLOADED_TOOLS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "data-protection-keys/", DataBackupEntryKind.INTERFACE_KEYS),
|
||||
new(DataBackupLocation.DATA_DIRECTORY, "events.log", DataBackupEntryKind.LOGS),
|
||||
|
||||
// The runtime hides the log of the startup on Linux and macOS:
|
||||
new(DataBackupLocation.DATA_DIRECTORY, OperatingSystem.IsWindows() ? "AI Studio Events.log" : ".AI Studio Events.log", DataBackupEntryKind.LOGS),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Whether a path lies within an entry: below it for a folder, or the entry itself for a file.
|
||||
/// </summary>
|
||||
/// <param name="entryPath">The path of the entry, as the list holds it.</param>
|
||||
/// <param name="relativePath">The path to check, relative to the same folder and with forward slashes.</param>
|
||||
/// <returns>True when the entry covers the path.</returns>
|
||||
public static bool Covers(string entryPath, string relativePath) => entryPath.EndsWith(SEPARATOR)
|
||||
? relativePath.StartsWith(entryPath, PathTools.COMPARISON)
|
||||
: relativePath.Equals(entryPath, PathTools.COMPARISON);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the plugins which the organization deployed into the folder of the user's plugins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The config server keeps the plugins of an organization in folders of their own, which the list
|
||||
/// leaves out as a whole. A device management may put them anywhere, though, so their path proves
|
||||
/// nothing: such a plugin declares itself as deployed by the organization instead. A backup leaves
|
||||
/// it out, because the organization brings it back, and a user with a backup tool of their own
|
||||
/// needs to know which folders these are.
|
||||
/// </remarks>
|
||||
/// <param name="plugins">The plugins AI Studio found.</param>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <returns>The folders of these plugins within the data folder, in the form of the list, ordered.</returns>
|
||||
public static IReadOnlyList<string> FindOrganizationPluginsAmongUserPlugins(IEnumerable<IAvailablePlugin> plugins, string dataDirectory)
|
||||
{
|
||||
var excludedPluginFolders = ENTRIES
|
||||
.Where(entry => !entry.IsIncluded && entry.IsFolder && entry.Location is DataBackupLocation.DATA_DIRECTORY && entry.RelativePath.StartsWith(PLUGINS_FOLDER, StringComparison.Ordinal))
|
||||
.Select(entry => entry.RelativePath)
|
||||
.ToList();
|
||||
|
||||
//
|
||||
// Relative to the data folder and with forward slashes, the plugin folders take the form of
|
||||
// the list. That is the form in which AI Studio stores such paths, too:
|
||||
//
|
||||
return plugins
|
||||
.Where(plugin => plugin.IsManagedByConfigServer)
|
||||
.Select(plugin => DataDirectoryRelativePathConverter.ToStored(plugin.LocalPath, dataDirectory).TrimEnd(SEPARATOR) + SEPARATOR)
|
||||
.Where(folder => folder.Length > PLUGINS_FOLDER.Length
|
||||
&& folder.StartsWith(PLUGINS_FOLDER, PathTools.COMPARISON)
|
||||
&& !excludedPluginFolders.Any(excluded => folder.StartsWith(excluded, PathTools.COMPARISON)))
|
||||
.Distinct(PathTools.COMPARER)
|
||||
.Order(PathTools.COMPARER)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// A file or folder AI Studio keeps in its data or configuration folder.
|
||||
/// </summary>
|
||||
/// <param name="Location">The folder in which the entry lies.</param>
|
||||
/// <param name="RelativePath">The path of the entry within that folder, with forward slashes. A folder ends with a slash.</param>
|
||||
/// <param name="Kind">What the entry holds, and with it, whether it belongs into a backup.</param>
|
||||
public sealed record DataBackupEntry(DataBackupLocation Location, string RelativePath, DataBackupEntryKind Kind)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the entry belongs into a backup.
|
||||
/// </summary>
|
||||
public bool IsIncluded => this.Kind is DataBackupEntryKind.SETTINGS
|
||||
or DataBackupEntryKind.WORKSPACES
|
||||
or DataBackupEntryKind.TEMPORARY_CHATS
|
||||
or DataBackupEntryKind.PLUGINS
|
||||
or DataBackupEntryKind.TOKENIZERS
|
||||
or DataBackupEntryKind.VISUAL_BRIEFINGS
|
||||
or DataBackupEntryKind.DATA_SOURCE_INDEX
|
||||
or DataBackupEntryKind.VECTOR_STORES;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the entry is a folder rather than a single file.
|
||||
/// </summary>
|
||||
public bool IsFolder => this.RelativePath.EndsWith(DataBackupContents.SEPARATOR);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What an entry of the backup contents holds, and with it, whether it belongs into a backup.
|
||||
/// </summary>
|
||||
public enum DataBackupEntryKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The settings. A backup leaves out what a configuration plugin of the organization manages.
|
||||
/// </summary>
|
||||
SETTINGS,
|
||||
|
||||
/// <summary>
|
||||
/// The chats of the workspaces, with their transcripts.
|
||||
/// </summary>
|
||||
WORKSPACES,
|
||||
|
||||
/// <summary>
|
||||
/// The temporary chats, with their transcripts.
|
||||
/// </summary>
|
||||
TEMPORARY_CHATS,
|
||||
|
||||
/// <summary>
|
||||
/// The plugins of the user. A backup leaves out the plugins of the organization.
|
||||
/// </summary>
|
||||
PLUGINS,
|
||||
|
||||
/// <summary>
|
||||
/// The copies of the tokenizers the user chose for a provider.
|
||||
/// </summary>
|
||||
TOKENIZERS,
|
||||
|
||||
/// <summary>
|
||||
/// The projects of the Visual Briefing Assistant.
|
||||
/// </summary>
|
||||
VISUAL_BRIEFINGS,
|
||||
|
||||
/// <summary>
|
||||
/// The SQLite index of the data sources: which files are embedded, and their text chunks.
|
||||
/// </summary>
|
||||
DATA_SOURCE_INDEX,
|
||||
|
||||
/// <summary>
|
||||
/// The vector stores, which hold the embeddings of the data sources.
|
||||
/// </summary>
|
||||
VECTOR_STORES,
|
||||
|
||||
/// <summary>
|
||||
/// The copies of the settings which every version of AI Studio keeps for itself.
|
||||
/// </summary>
|
||||
SETTINGS_OF_OTHER_VERSIONS,
|
||||
|
||||
/// <summary>
|
||||
/// The plugins which come with AI Studio, such as the language packs.
|
||||
/// </summary>
|
||||
BUILT_IN_PLUGINS,
|
||||
|
||||
/// <summary>
|
||||
/// The plugins which the organization deploys, or tries out for one session.
|
||||
/// </summary>
|
||||
ORGANIZATION_PLUGINS,
|
||||
|
||||
/// <summary>
|
||||
/// Files AI Studio needs only for a moment, while it works on something, and removes again.
|
||||
/// </summary>
|
||||
TEMPORARY_FILES,
|
||||
|
||||
/// <summary>
|
||||
/// Tools AI Studio downloaded, which depend on the OS, such as Pandoc.
|
||||
/// </summary>
|
||||
DOWNLOADED_TOOLS,
|
||||
|
||||
/// <summary>
|
||||
/// The keys with which the user interface protects its data. Only the Flatpak keeps them in the data
|
||||
/// folder, since the home folder is read-only there, and AI Studio creates new ones when they are missing.
|
||||
/// </summary>
|
||||
INTERFACE_KEYS,
|
||||
|
||||
/// <summary>
|
||||
/// The log files.
|
||||
/// </summary>
|
||||
LOGS,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// The format of a backup: a ZIP file which holds the data folder below DATA_ROOT and the
|
||||
/// configuration folder below CONFIG_ROOT, each in the layout AI Studio keeps on the disk, and the
|
||||
/// manifest as its last entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What goes in follows DataBackupContents. The paths below the roots are the relative paths of
|
||||
/// that list, so restoring a backup is unpacking it. The manifest comes last: an archive which lacks
|
||||
/// it was not written completely.
|
||||
/// </remarks>
|
||||
public static class DataBackupFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// The version of the format this version of AI Studio writes. A restore refuses a backup of a
|
||||
/// later version, since it cannot know what such a backup holds.
|
||||
/// </summary>
|
||||
public const int FORMAT_VERSION = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The file extension of a backup, named like the one of plugin archives.
|
||||
/// </summary>
|
||||
public const string FILE_EXTENSION = ".mwbackup";
|
||||
|
||||
/// <summary>
|
||||
/// The manifest, the last entry of every backup.
|
||||
/// </summary>
|
||||
public const string MANIFEST_ENTRY_NAME = "manifest.json";
|
||||
|
||||
/// <summary>
|
||||
/// Where the data folder lies within a backup.
|
||||
/// </summary>
|
||||
public const string DATA_ROOT = "data/";
|
||||
|
||||
/// <summary>
|
||||
/// Where the configuration folder lies within a backup.
|
||||
/// </summary>
|
||||
public const string CONFIG_ROOT = "config/";
|
||||
|
||||
/// <summary>
|
||||
/// The directory in the data folder where the copies of the vector stores wait while a backup runs.
|
||||
/// The runtime creates it; keep the name in sync with BACKUP_STAGING_DIRECTORY there.
|
||||
/// </summary>
|
||||
public const string STAGING_DIRECTORY = ".backup-staging/";
|
||||
|
||||
/// <summary>
|
||||
/// How the manifest and the plan of a restore are written: readable for anybody who opens them,
|
||||
/// and for the runtime, which applies the plan.
|
||||
/// </summary>
|
||||
public static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// File types which are compressed already. Compressing them once more costs time and makes the
|
||||
/// backup no smaller.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> ALREADY_COMPRESSED_EXTENSIONS = new HashSet<string>(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",
|
||||
".mwplugin", FILE_EXTENSION,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The name of the entry which holds a file of the data or configuration folder.
|
||||
/// </summary>
|
||||
/// <param name="location">The folder of the file.</param>
|
||||
/// <param name="relativePath">The path of the file within its folder, with forward slashes.</param>
|
||||
/// <returns>The name of the entry.</returns>
|
||||
public static string EntryName(DataBackupLocation location, string relativePath) => location switch
|
||||
{
|
||||
DataBackupLocation.CONFIG_DIRECTORY => CONFIG_ROOT + relativePath,
|
||||
_ => DATA_ROOT + relativePath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// The folder in which an entry of the backup contents lies.
|
||||
/// </summary>
|
||||
public enum DataBackupLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// The data folder, where AI Studio keeps the chats, the plugins, and the indexes of the data sources.
|
||||
/// </summary>
|
||||
DATA_DIRECTORY,
|
||||
|
||||
/// <summary>
|
||||
/// The configuration folder, where AI Studio keeps its settings.
|
||||
/// </summary>
|
||||
CONFIG_DIRECTORY,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What a backup holds, and what wrote it.
|
||||
/// </summary>
|
||||
/// <param name="FormatVersion">The version of the format, cf. DataBackupFormat.FORMAT_VERSION.</param>
|
||||
/// <param name="AppVersion">The version of AI Studio which wrote the backup.</param>
|
||||
/// <param name="SettingsVersion">The version of the settings in the backup.</param>
|
||||
/// <param name="CreatedAtUtc">When the backup was written.</param>
|
||||
/// <param name="SourceDataDirectory">The data folder the backup was made from, for whoever wonders where it came from.</param>
|
||||
/// <param name="SourceConfigDirectory">The configuration folder the backup was made from.</param>
|
||||
/// <param name="IncludesDataSourceIndexes">Whether the backup holds the indexes of the data sources. Without them, a restore keeps the indexes there are.</param>
|
||||
/// <param name="Entries">Every file of the backup, the manifest aside.</param>
|
||||
public sealed record DataBackupManifest(
|
||||
int FormatVersion,
|
||||
string AppVersion,
|
||||
string SettingsVersion,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string SourceDataDirectory,
|
||||
string SourceConfigDirectory,
|
||||
bool IncludesDataSourceIndexes,
|
||||
IReadOnlyList<DataBackupManifestEntry> Entries);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// One file of a backup, as the manifest lists it.
|
||||
/// </summary>
|
||||
/// <param name="Name">The name of the entry within the backup.</param>
|
||||
/// <param name="Size">The size of the file in bytes.</param>
|
||||
/// <param name="Sha256">The SHA-256 hash of the file, in lowercase hex digits.</param>
|
||||
public sealed record DataBackupManifestEntry(string Name, long Size, string Sha256);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// The manifest of a backup, or why it could not be read.
|
||||
/// </summary>
|
||||
/// <param name="Manifest">The manifest, or null when the backup cannot be used.</param>
|
||||
/// <param name="Issue">Why the backup cannot be used, for the user. Empty when it can.</param>
|
||||
public sealed record DataBackupManifestReadResult(DataBackupManifest? Manifest, string Issue);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What a backup is busy with.
|
||||
/// </summary>
|
||||
public enum DataBackupPhase
|
||||
{
|
||||
/// <summary>
|
||||
/// It collects the settings and pauses the indexing.
|
||||
/// </summary>
|
||||
PREPARING,
|
||||
|
||||
/// <summary>
|
||||
/// It copies the indexes of the data sources, one vector store after the other.
|
||||
/// </summary>
|
||||
COPYING_INDEXES,
|
||||
|
||||
/// <summary>
|
||||
/// It writes the files into the backup.
|
||||
/// </summary>
|
||||
WRITING,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How far a backup got.
|
||||
/// </summary>
|
||||
/// <param name="Phase">What the backup is busy with.</param>
|
||||
/// <param name="Processed">How many steps of the phase are done: vector stores while the indexes are copied, files while they are written.</param>
|
||||
/// <param name="Total">How many steps the phase has, or zero while that is not known yet.</param>
|
||||
public readonly record struct DataBackupProgress(DataBackupPhase Phase, int Processed, int Total);
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Reads what a backup holds, cf. DataBackupFormat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Open a backup with ZipArchiveMode.Read and read its entries as streams. ZipArchiveMode.Update would
|
||||
/// buffer an entry in memory, which ends at 2 GB, and a backup may hold larger files.
|
||||
/// </remarks>
|
||||
public static class DataBackupReader
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataBackupReader).Namespace, nameof(DataBackupReader));
|
||||
|
||||
/// <summary>
|
||||
/// Reads the manifest of a backup, and checks that this version of AI Studio can use the backup.
|
||||
/// </summary>
|
||||
/// <param name="archive">The backup, opened for reading.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The manifest, or why the backup cannot be used.</returns>
|
||||
public static async Task<DataBackupManifestReadResult> ReadManifestAsync(ZipArchive archive, CancellationToken token)
|
||||
{
|
||||
var manifestEntry = archive.GetEntry(DataBackupFormat.MANIFEST_ENTRY_NAME);
|
||||
if (manifestEntry is null)
|
||||
return new(null, TB("This file is no backup of AI Studio, or it was not written completely."));
|
||||
|
||||
DataBackupManifest? manifest;
|
||||
try
|
||||
{
|
||||
await using var manifestStream = manifestEntry.Open();
|
||||
manifest = await JsonSerializer.DeserializeAsync<DataBackupManifest>(manifestStream, DataBackupFormat.JSON_OPTIONS, token);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
manifest = null;
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
return new(null, TB("The content list of this backup could not be read."));
|
||||
|
||||
if (manifest.FormatVersion > DataBackupFormat.FORMAT_VERSION)
|
||||
return new(null, TB("This backup was made by a newer version of AI Studio. Please update AI Studio first."));
|
||||
|
||||
return new(manifest, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How a backup ended.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the backup was written completely. A cancelled backup counts as no success.</param>
|
||||
/// <param name="Cancelled">Whether the user stopped the backup.</param>
|
||||
/// <param name="ArchivePath">The file of the backup.</param>
|
||||
/// <param name="FileCount">How many files the backup holds.</param>
|
||||
/// <param name="Bytes">How large these files are together, before they were compressed.</param>
|
||||
/// <param name="IncludesDataSourceIndexes">Whether the backup holds the indexes of the data sources.</param>
|
||||
/// <param name="Issue">Why the backup failed, for the user. Empty when it did not fail.</param>
|
||||
public sealed record DataBackupResult(bool Success, bool Cancelled, string ArchivePath, int FileCount, long Bytes, bool IncludesDataSourceIndexes, string Issue);
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// The settings as a backup takes them along.
|
||||
/// </summary>
|
||||
public static class DataBackupSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies the settings, leaving out every object which a configuration plugin manages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A configuration plugin brings its providers, data sources, chat templates, profiles, and
|
||||
/// policies back on another computer, along with their API keys. Restored from a backup as well,
|
||||
/// they would compete with what the plugin brings, and without the plugin they would stay without
|
||||
/// their keys: secrets never go into a backup. That holds for the configuration plugins of an
|
||||
/// organization and for those the user installed, since a backup takes the latter along.<br/><br/>
|
||||
///
|
||||
/// A setting which a configuration plugin locks stays in the copy, together with the plugin which
|
||||
/// locks it. After a restore, the clean-up following the start of the plugins treats it like the
|
||||
/// setting of a removed plugin, until the plugin locks it again. Without the plugin, the user gets
|
||||
/// back the value they had chosen before.<br/><br/>
|
||||
///
|
||||
/// The copy is a round trip through the format in which the settings are stored, so it shares
|
||||
/// nothing with the settings AI Studio runs with. Like storing the settings, it reads them while
|
||||
/// the rest of the app may still change them.
|
||||
/// </remarks>
|
||||
/// <param name="settings">The settings AI Studio runs with. They stay as they are.</param>
|
||||
/// <returns>The copy for the backup.</returns>
|
||||
public static Data CopyWithoutPluginObjects(Data settings)
|
||||
{
|
||||
var copy = JsonSerializer.Deserialize<Data>(JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS), SettingsManager.JSON_OPTIONS)
|
||||
?? throw new InvalidOperationException("The settings could not be copied.");
|
||||
|
||||
foreach (var descriptor in PluginConfigurationObjectDescriptors.ALL)
|
||||
descriptor.RemovePluginObjects(copy);
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Where a backup takes its files from.
|
||||
/// </summary>
|
||||
/// <param name="DataDirectory">The data folder.</param>
|
||||
/// <param name="ConfigDirectory">The configuration folder.</param>
|
||||
/// <param name="SettingsJson">The settings as the backup takes them along, cf. DataBackupSettings. They come from memory rather than from the disk, since the backup leaves out the objects of configuration plugins.</param>
|
||||
/// <param name="IndexCopiesDirectory">The copies of the indexes, in the layout of the data folder, or an empty string when the backup leaves the indexes out. The indexes in the data folder change while AI Studio runs, so the backup never reads them there.</param>
|
||||
/// <param name="OrganizationPluginFolders">The plugins an organization deployed among the user's own ones, cf. DataBackupContents.FindOrganizationPluginsAmongUserPlugins.</param>
|
||||
public sealed record DataBackupSources(string DataDirectory, string ConfigDirectory, string SettingsJson, string IndexCopiesDirectory, IReadOnlyList<string> OrganizationPluginFolders);
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a backup, cf. DataBackupFormat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every file is streamed into the backup, so a backup of many gigabytes needs no more memory than
|
||||
/// a small one. The caller writes the backup straight into the file the user chose: in the Flatpak,
|
||||
/// the portal grants access to exactly that file, and to nothing next to it.
|
||||
/// </remarks>
|
||||
public static class DataBackupWriter
|
||||
{
|
||||
private const int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The earliest time a ZIP file can store.
|
||||
/// </summary>
|
||||
private static readonly DateTimeOffset EARLIEST_ZIP_TIME = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// A file to take along, and where it goes in the backup.
|
||||
/// </summary>
|
||||
private sealed record BackupFile(string EntryName, string SourcePath);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a backup into the given stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A file which is gone by the time it is written, e.g. a temporary chat AI Studio deleted
|
||||
/// meanwhile, is left out. A file which cannot be read fails the backup, though: a backup which
|
||||
/// quietly lacks something is worse than none.
|
||||
/// </remarks>
|
||||
/// <param name="target">Where to write the backup. It stays open.</param>
|
||||
/// <param name="sources">Where the files come from.</param>
|
||||
/// <param name="manifest">What to note in the manifest. The entries are filled in here.</param>
|
||||
/// <param name="progress">Receives how many files are written.</param>
|
||||
/// <param name="token">Cancels the backup.</param>
|
||||
/// <returns>The manifest as the backup holds it.</returns>
|
||||
public static async Task<DataBackupManifest> WriteAsync(Stream target, DataBackupSources sources, DataBackupManifest manifest, IProgress<DataBackupProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var files = CollectFiles(sources, token);
|
||||
var totalFiles = files.Count + 1;
|
||||
var entries = new List<DataBackupManifestEntry>(totalFiles);
|
||||
var buffer = new byte[BUFFER_SIZE];
|
||||
|
||||
progress?.Report(new(DataBackupPhase.WRITING, 0, totalFiles));
|
||||
using (var archive = new ZipArchive(target, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var settingsEntry = DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.SETTINGS);
|
||||
await using (var settingsStream = new MemoryStream(Encoding.UTF8.GetBytes(sources.SettingsJson), writable: false))
|
||||
entries.Add(await WriteEntryAsync(archive, DataBackupFormat.EntryName(settingsEntry.Location, settingsEntry.RelativePath), settingsStream, DateTimeOffset.Now, buffer, token));
|
||||
|
||||
progress?.Report(new(DataBackupPhase.WRITING, entries.Count, totalFiles));
|
||||
foreach (var file in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
FileStream source;
|
||||
try
|
||||
{
|
||||
source = new FileStream(file.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await using (source)
|
||||
entries.Add(await WriteEntryAsync(archive, file.EntryName, source, File.GetLastWriteTime(file.SourcePath), buffer, token));
|
||||
|
||||
progress?.Report(new(DataBackupPhase.WRITING, entries.Count, totalFiles));
|
||||
}
|
||||
|
||||
manifest = manifest with { Entries = entries };
|
||||
await using (var manifestStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(manifest, DataBackupFormat.JSON_OPTIONS), writable: false))
|
||||
await WriteEntryAsync(archive, DataBackupFormat.MANIFEST_ENTRY_NAME, manifestStream, DateTimeOffset.Now, buffer, token);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the files of the data folder which belong into a backup, cf. DataBackupContents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings are no part of it: they come from memory. The indexes come from their copies.
|
||||
/// </remarks>
|
||||
private static List<BackupFile> CollectFiles(DataBackupSources sources, CancellationToken token)
|
||||
{
|
||||
var excludedPaths = DataBackupContents.ENTRIES
|
||||
.Where(entry => !entry.IsIncluded && entry.Location is DataBackupLocation.DATA_DIRECTORY)
|
||||
.Select(entry => entry.RelativePath)
|
||||
.Concat(sources.OrganizationPluginFolders)
|
||||
.ToList();
|
||||
|
||||
var files = new List<BackupFile>();
|
||||
foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded && entry.Location is DataBackupLocation.DATA_DIRECTORY))
|
||||
{
|
||||
var root = entry.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.VECTOR_STORES
|
||||
? sources.IndexCopiesDirectory
|
||||
: sources.DataDirectory;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
continue;
|
||||
|
||||
var path = Path.Join(root, entry.RelativePath.Replace(DataBackupContents.SEPARATOR, Path.DirectorySeparatorChar));
|
||||
if (!entry.IsFolder)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
files.Add(new(DataBackupFormat.EntryName(entry.Location, entry.RelativePath), path));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
continue;
|
||||
|
||||
//
|
||||
// Hidden files count as well, since on Linux and macOS a dot makes a file hidden. Links
|
||||
// are not followed, so nothing outside the folder gets into the backup. A folder which
|
||||
// cannot be read fails the backup instead of leaving a gap:
|
||||
//
|
||||
var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false };
|
||||
foreach (var filePath in Directory.EnumerateFiles(path, "*", options))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = Path.GetRelativePath(root, filePath).Replace(Path.DirectorySeparatorChar, DataBackupContents.SEPARATOR);
|
||||
if (excludedPaths.Any(excludedPath => DataBackupContents.Covers(excludedPath, relativePath)))
|
||||
continue;
|
||||
|
||||
files.Add(new(DataBackupFormat.EntryName(DataBackupLocation.DATA_DIRECTORY, relativePath), filePath));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one entry, and notes its size and hash while it streams through.
|
||||
/// </summary>
|
||||
private static async Task<DataBackupManifestEntry> WriteEntryAsync(ZipArchive archive, string entryName, Stream source, DateTimeOffset lastWriteTime, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
var compressionLevel = DataBackupFormat.ALREADY_COMPRESSED_EXTENSIONS.Contains(Path.GetExtension(entryName))
|
||||
? CompressionLevel.NoCompression
|
||||
: CompressionLevel.Optimal;
|
||||
|
||||
var entry = archive.CreateEntry(entryName, compressionLevel);
|
||||
|
||||
// The time of the last change tells, e.g., the order of the chats in a workspace:
|
||||
entry.LastWriteTime = lastWriteTime < EARLIEST_ZIP_TIME ? DateTimeOffset.Now : lastWriteTime;
|
||||
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
long size = 0;
|
||||
await using (var entryStream = entry.Open())
|
||||
{
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, token)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await entryStream.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
size += read;
|
||||
}
|
||||
}
|
||||
|
||||
return new(entryName, size, Convert.ToHexStringLower(hash.GetHashAndReset()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// One element which a restore replaces, as the list of the backup contents names it.
|
||||
/// </summary>
|
||||
/// <param name="Location">The folder in which the element lies.</param>
|
||||
/// <param name="RelativePath">The path of the element within that folder, with forward slashes. A folder ends with a slash.</param>
|
||||
/// <param name="Keep">The paths below the element which stay as they are, relative to the same folder: the plugins which come with AI Studio or from the organization, for example. The runtime moves them from the replaced element into the new one.</param>
|
||||
public sealed record DataRestoreElement(DataBackupLocation Location, string RelativePath, IReadOnlyList<string> Keep);
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What remains to do after the runtime applied a restore, or after a backup or the preparation of a
|
||||
/// restore stopped halfway: read the report of the runtime, and remove what nobody needs any longer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing here touches a restore which the runtime has not finished, cf. IsRestoreUnfinished: its
|
||||
/// journal and the data it replaced are the only way back, and the runtime continues at every start.
|
||||
/// </remarks>
|
||||
public static class DataRestoreFollowUp
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a restore is prepared completely and waits for the next start.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <returns>True when the next start applies a restore.</returns>
|
||||
public static bool IsRestorePending(string dataDirectory) => IsMarkedReady(dataDirectory) && !HasJournal(dataDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the runtime has not finished a restore: it failed to move a failed restore back, and
|
||||
/// tries again at the next start.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <returns>True while the runtime still needs the pending and the previous directories.</returns>
|
||||
public static bool IsRestoreUnfinished(string dataDirectory) => IsMarkedReady(dataDirectory) && HasJournal(dataDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Reads how the runtime applied a restore at this start. Throws a JsonException when the report
|
||||
/// cannot be read.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <param name="token">Cancels reading.</param>
|
||||
/// <returns>The report, or null when the runtime applied no restore.</returns>
|
||||
public static async Task<DataRestoreReport?> ReadReportAsync(string dataDirectory, CancellationToken token)
|
||||
{
|
||||
var reportPath = Path.Join(dataDirectory, DataRestoreFormat.RESULT_FILE_NAME);
|
||||
if (!File.Exists(reportPath))
|
||||
return null;
|
||||
|
||||
await using var reportStream = File.OpenRead(reportPath);
|
||||
return await JsonSerializer.DeserializeAsync<DataRestoreReport>(reportStream, DataBackupFormat.JSON_OPTIONS, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the report of the runtime, once the user saw it.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
public static void RemoveReport(string dataDirectory) => File.Delete(Path.Join(dataDirectory, DataRestoreFormat.RESULT_FILE_NAME));
|
||||
|
||||
/// <summary>
|
||||
/// Removes what a backup or the preparation of a restore left behind when it stopped halfway, e.g.,
|
||||
/// because AI Studio crashed. Call it only while neither runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A pending directory with READY_FILE_NAME stays: the restore waits for the next start, or the
|
||||
/// runtime has not finished it.
|
||||
/// </remarks>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <param name="configDirectory">The configuration folder.</param>
|
||||
public static void RemoveLeftovers(string dataDirectory, string configDirectory)
|
||||
{
|
||||
DeleteDirectory(DataRestoreFormat.DirectoryIn(dataDirectory, DataBackupFormat.STAGING_DIRECTORY));
|
||||
if (IsMarkedReady(dataDirectory))
|
||||
return;
|
||||
|
||||
DeleteDirectory(DataRestoreFormat.DirectoryIn(dataDirectory, DataRestoreFormat.PENDING_DIRECTORY));
|
||||
DeleteDirectory(DataRestoreFormat.DirectoryIn(configDirectory, DataRestoreFormat.PENDING_DIRECTORY));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the data which a restore replaced. Call it once the restored settings loaded: until
|
||||
/// then, these data are the way back.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">The data folder.</param>
|
||||
/// <param name="configDirectory">The configuration folder.</param>
|
||||
/// <returns>Whether there were such data.</returns>
|
||||
public static bool RemoveReplacedData(string dataDirectory, string configDirectory)
|
||||
{
|
||||
if (IsMarkedReady(dataDirectory))
|
||||
return false;
|
||||
|
||||
var removedData = DeleteDirectory(DataRestoreFormat.DirectoryIn(dataDirectory, DataRestoreFormat.PREVIOUS_DIRECTORY));
|
||||
var removedConfiguration = DeleteDirectory(DataRestoreFormat.DirectoryIn(configDirectory, DataRestoreFormat.PREVIOUS_DIRECTORY));
|
||||
return removedData || removedConfiguration;
|
||||
}
|
||||
|
||||
private static bool IsMarkedReady(string dataDirectory) => File.Exists(Path.Join(DataRestoreFormat.DirectoryIn(dataDirectory, DataRestoreFormat.PENDING_DIRECTORY), DataRestoreFormat.READY_FILE_NAME));
|
||||
|
||||
private static bool HasJournal(string dataDirectory) => File.Exists(Path.Join(DataRestoreFormat.DirectoryIn(dataDirectory, DataRestoreFormat.PENDING_DIRECTORY), DataRestoreFormat.JOURNAL_FILE_NAME));
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a directory with everything in it.
|
||||
/// </summary>
|
||||
/// <returns>Whether the directory existed.</returns>
|
||||
private static bool DeleteDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
return false;
|
||||
|
||||
Directory.Delete(path, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How a prepared restore waits for the next start, which applies it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the contract between the app, which prepares a restore, and the runtime, which applies it
|
||||
/// before the app starts again, when no file is in use:
|
||||
/// <list type="bullet">
|
||||
/// <item>Below the data folder and below the configuration folder, PENDING_DIRECTORY holds what the
|
||||
/// backup brings for that folder, in the layout of that folder. Staging lies on the same disk as its
|
||||
/// target, so the runtime can move every element into place instead of copying it.</item>
|
||||
/// <item>The plan, PLAN_FILE_NAME in the pending directory of the data folder, names the elements to
|
||||
/// replace, cf. DataRestorePlan.</item>
|
||||
/// <item>READY_FILE_NAME in the same directory comes last. Without it, the restore was not prepared
|
||||
/// completely, and the runtime leaves everything as it is.</item>
|
||||
/// </list>
|
||||
/// What the runtime adds, cf. runtime/src/data_restore.rs:
|
||||
/// <list type="bullet">
|
||||
/// <item>PREVIOUS_DIRECTORY, below both folders, keeps what the restore replaced. The app removes it
|
||||
/// once it loaded the restored settings.</item>
|
||||
/// <item>JOURNAL_FILE_NAME in the pending directory of the data folder lists every move. While it lies
|
||||
/// next to READY_FILE_NAME, the runtime moves everything back at the next start, so the app never
|
||||
/// removes a pending directory which holds READY_FILE_NAME.</item>
|
||||
/// <item>RESULT_FILE_NAME in the data folder tells the app how the restore ended, cf. DataRestoreReport.</item>
|
||||
/// </list>
|
||||
/// Keep in sync with the runtime, which applies the restore.
|
||||
/// </remarks>
|
||||
public static class DataRestoreFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// The version of the plan this version of AI Studio writes.
|
||||
/// </summary>
|
||||
public const int PLAN_FORMAT_VERSION = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The directory, below the data folder and below the configuration folder, which holds what a
|
||||
/// prepared restore brings for that folder.
|
||||
/// </summary>
|
||||
public const string PENDING_DIRECTORY = ".restore-pending/";
|
||||
|
||||
/// <summary>
|
||||
/// The plan of the restore, in the pending directory of the data folder.
|
||||
/// </summary>
|
||||
public const string PLAN_FILE_NAME = "restore.json";
|
||||
|
||||
/// <summary>
|
||||
/// Marks a restore as prepared completely, in the pending directory of the data folder.
|
||||
/// </summary>
|
||||
public const string READY_FILE_NAME = "READY";
|
||||
|
||||
/// <summary>
|
||||
/// The directory, below the data folder and below the configuration folder, which keeps what the
|
||||
/// runtime replaced, in the layout of that folder.
|
||||
/// </summary>
|
||||
public const string PREVIOUS_DIRECTORY = ".restore-previous/";
|
||||
|
||||
/// <summary>
|
||||
/// How the runtime applied a restore, in the data folder.
|
||||
/// </summary>
|
||||
public const string RESULT_FILE_NAME = ".restore-result.json";
|
||||
|
||||
/// <summary>
|
||||
/// The journal of the runtime, in the pending directory of the data folder. Next to READY_FILE_NAME,
|
||||
/// it tells that the runtime has not finished a restore: it moves it back at the next start.
|
||||
/// </summary>
|
||||
public const string JOURNAL_FILE_NAME = "journal.json";
|
||||
|
||||
/// <summary>
|
||||
/// The path of one of the directories above, below the given folder.
|
||||
/// </summary>
|
||||
/// <param name="folder">The data or the configuration folder.</param>
|
||||
/// <param name="directory">PENDING_DIRECTORY or PREVIOUS_DIRECTORY.</param>
|
||||
/// <returns>The path of the directory.</returns>
|
||||
public static string DirectoryIn(string folder, string directory) => Path.Join(folder, directory.TrimEnd(DataBackupContents.SEPARATOR));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// One thing the user still has to do after a restore.
|
||||
/// </summary>
|
||||
/// <param name="Kind">What to do.</param>
|
||||
/// <param name="Name">What it concerns: the name of a provider, a data source, or a tool, or a path.</param>
|
||||
/// <param name="Setting">For a tool: the label of the setting which lacks its value; otherwise empty.</param>
|
||||
public sealed record DataRestoreOpenPoint(DataRestoreOpenPointKind Kind, string Name, string Setting = "");
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What the user still has to do after a restore, since a backup holds no secrets and another computer
|
||||
/// may lack the files the settings name.
|
||||
/// </summary>
|
||||
public enum DataRestoreOpenPointKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A provider of the user has no API key on this computer.
|
||||
/// </summary>
|
||||
PROVIDER_WITHOUT_API_KEY,
|
||||
|
||||
/// <summary>
|
||||
/// An embedding provider of the user has no API key on this computer.
|
||||
/// </summary>
|
||||
EMBEDDING_PROVIDER_WITHOUT_API_KEY,
|
||||
|
||||
/// <summary>
|
||||
/// A transcription provider of the user has no API key on this computer.
|
||||
/// </summary>
|
||||
TRANSCRIPTION_PROVIDER_WITHOUT_API_KEY,
|
||||
|
||||
/// <summary>
|
||||
/// An ERI data source of the user has no password or token on this computer.
|
||||
/// </summary>
|
||||
DATA_SOURCE_WITHOUT_SECRET,
|
||||
|
||||
/// <summary>
|
||||
/// A mailbox of the user has no password on this computer.
|
||||
/// </summary>
|
||||
MAILBOX_WITHOUT_PASSWORD,
|
||||
|
||||
/// <summary>
|
||||
/// A tool which the user set up has no value for a secret setting on this computer, e.g., for
|
||||
/// the API key of a search service.
|
||||
/// </summary>
|
||||
TOOL_WITHOUT_SECRET,
|
||||
|
||||
/// <summary>
|
||||
/// A local data source names a file or folder which does not exist on this computer.
|
||||
/// </summary>
|
||||
DATA_SOURCE_NOT_FOUND,
|
||||
|
||||
/// <summary>
|
||||
/// The batch processing names a file or folder which does not exist on this computer.
|
||||
/// </summary>
|
||||
BATCH_PROCESSING_PATH_NOT_FOUND,
|
||||
|
||||
/// <summary>
|
||||
/// The file with custom root certificates does not exist on this computer.
|
||||
/// </summary>
|
||||
CERTIFICATE_BUNDLE_NOT_FOUND,
|
||||
|
||||
/// <summary>
|
||||
/// The ERI server assistant writes a server into a folder which does not exist on this computer.
|
||||
/// </summary>
|
||||
ERI_SERVER_DIRECTORY_NOT_FOUND,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Finds what the user still has to do after a restore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backup holds no secrets, so the providers, data sources, mailboxes, and tools of the user lack
|
||||
/// them on another computer. Those of the organization do not: their configuration plugin brings them
|
||||
/// along with their secrets. The files and folders which the settings name may be missing, too, wherever
|
||||
/// they come from.
|
||||
/// </remarks>
|
||||
public static class DataRestoreOpenPoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds what the user still has to do after a restore.
|
||||
/// </summary>
|
||||
/// <param name="settings">The restored settings.</param>
|
||||
/// <param name="secrets">Tells which secrets this computer holds.</param>
|
||||
/// <param name="toolSecrets">The secret settings of the tools which the user set up. Only the tools know which of their settings are secret, cf. DataRestoreService.</param>
|
||||
/// <returns>The open points, in the order of the settings.</returns>
|
||||
public static async Task<IReadOnlyList<DataRestoreOpenPoint>> CollectAsync(Data settings, IStoredSecrets secrets, IReadOnlyList<DataRestoreToolSecret> toolSecrets)
|
||||
{
|
||||
var openPoints = new List<DataRestoreOpenPoint>();
|
||||
foreach (var provider in settings.Providers.Where(provider => !provider.IsEnterpriseConfiguration && provider.UsedLLMProvider.IsAPIKeyNeeded(provider.Host)))
|
||||
if (!await secrets.HasAPIKeyAsync(provider, SecretStoreType.LLM_PROVIDER))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.PROVIDER_WITHOUT_API_KEY, provider.InstanceName));
|
||||
|
||||
foreach (var provider in settings.EmbeddingProviders.Where(provider => !provider.IsEnterpriseConfiguration && provider.UsedLLMProvider.IsAPIKeyNeeded(provider.Host)))
|
||||
if (!await secrets.HasAPIKeyAsync(provider, SecretStoreType.EMBEDDING_PROVIDER))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.EMBEDDING_PROVIDER_WITHOUT_API_KEY, provider.Name));
|
||||
|
||||
foreach (var provider in settings.TranscriptionProviders.Where(provider => !provider.IsEnterpriseConfiguration && provider.UsedLLMProvider.IsAPIKeyNeeded(provider.Host)))
|
||||
if (!await secrets.HasAPIKeyAsync(provider, SecretStoreType.TRANSCRIPTION_PROVIDER))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.TRANSCRIPTION_PROVIDER_WITHOUT_API_KEY, provider.Name));
|
||||
|
||||
foreach (var dataSource in settings.DataSources)
|
||||
{
|
||||
switch (dataSource)
|
||||
{
|
||||
case DataSourceLocalDirectory directory when !Directory.Exists(directory.Path):
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.DATA_SOURCE_NOT_FOUND, directory.Name));
|
||||
break;
|
||||
|
||||
case DataSourceLocalFile file when !File.Exists(file.FilePath):
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.DATA_SOURCE_NOT_FOUND, file.Name));
|
||||
break;
|
||||
|
||||
case IERIDataSource { IsEnterpriseConfiguration: false, AuthMethod: AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD } eriDataSource:
|
||||
if (!await secrets.HasSecretAsync(eriDataSource, SecretStoreType.DATA_SOURCE))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.DATA_SOURCE_WITHOUT_SECRET, eriDataSource.Name));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var mailbox in settings.Mailboxes.Where(mailbox => mailbox is { IsEnterpriseConfiguration: false, AuthMethod: MailboxAuthMethod.PASSWORD }))
|
||||
if (!await secrets.HasSecretAsync(mailbox, SecretStoreType.DATA_SOURCE))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.MAILBOX_WITHOUT_PASSWORD, mailbox.Name));
|
||||
|
||||
foreach (var toolSecret in toolSecrets)
|
||||
if (!await secrets.HasSecretAsync(new ToolSettingsSecretId(toolSecret.ToolId, toolSecret.FieldName), SecretStoreType.TOOL_SETTINGS))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.TOOL_WITHOUT_SECRET, toolSecret.ToolName, toolSecret.SettingLabel));
|
||||
|
||||
var batchProcessing = settings.BatchProcessing;
|
||||
foreach (var folder in new[] { batchProcessing.InputDirectory, batchProcessing.OutputDirectory })
|
||||
if (!string.IsNullOrWhiteSpace(folder) && !Directory.Exists(folder))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.BATCH_PROCESSING_PATH_NOT_FOUND, folder));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(batchProcessing.PromptFilePath) && !File.Exists(batchProcessing.PromptFilePath))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.BATCH_PROCESSING_PATH_NOT_FOUND, batchProcessing.PromptFilePath));
|
||||
|
||||
var certificateBundlePath = settings.App.ExternalHttpCustomRootCertificateBundlePath;
|
||||
if (settings.App.ExternalHttpCustomRootCertificatesEnabled && !string.IsNullOrWhiteSpace(certificateBundlePath) && !File.Exists(certificateBundlePath))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.CERTIFICATE_BUNDLE_NOT_FOUND, certificateBundlePath));
|
||||
|
||||
foreach (var server in settings.ERI.ERIServers.Where(server => server.WriteToFilesystem && !string.IsNullOrWhiteSpace(server.BaseDirectory) && !Directory.Exists(server.BaseDirectory)))
|
||||
openPoints.Add(new(DataRestoreOpenPointKind.ERI_SERVER_DIRECTORY_NOT_FOUND, server.ServerName));
|
||||
|
||||
return openPoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How the runtime applied a restore, cf. DataRestoreReport.
|
||||
/// </summary>
|
||||
public enum DataRestoreOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// The restored data are in place.
|
||||
/// </summary>
|
||||
APPLIED,
|
||||
|
||||
/// <summary>
|
||||
/// The runtime did not start the restore, since the plan or the folders did not allow it. All data
|
||||
/// stayed as they were.
|
||||
/// </summary>
|
||||
REFUSED,
|
||||
|
||||
/// <summary>
|
||||
/// The restore failed, and the runtime moved all data back.
|
||||
/// </summary>
|
||||
ROLLED_BACK,
|
||||
|
||||
/// <summary>
|
||||
/// The restore failed, and moving the data back failed as well. The runtime tries again at every
|
||||
/// start.
|
||||
/// </summary>
|
||||
ROLLBACK_FAILED,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// What the runtime replaces at the next start, cf. DataRestoreFormat.
|
||||
/// </summary>
|
||||
/// <param name="FormatVersion">The version of the plan, cf. DataRestoreFormat.PLAN_FORMAT_VERSION.</param>
|
||||
/// <param name="PreparedAtUtc">When the restore was prepared.</param>
|
||||
/// <param name="BackupCreatedAtUtc">When the backup was made.</param>
|
||||
/// <param name="BackupAppVersion">The version of AI Studio which made the backup.</param>
|
||||
/// <param name="Elements">The elements to replace. An element the backup holds nothing for stays as it is.</param>
|
||||
public sealed record DataRestorePlan(int FormatVersion, DateTimeOffset PreparedAtUtc, DateTimeOffset BackupCreatedAtUtc, string BackupAppVersion, IReadOnlyList<DataRestoreElement> Elements);
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using SettingsVersion = AIStudio.Settings.Version;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares a restore: unpacks a backup next to the data and configuration folders, so that the next
|
||||
/// start can put it in place, cf. DataRestoreFormat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing in use changes here. While AI Studio runs, its files are open, and on Windows, they cannot be
|
||||
/// replaced at all. The runtime replaces them at the next start, before the app opens any of them, and
|
||||
/// rolls back when that fails.
|
||||
/// </remarks>
|
||||
public static class DataRestorePreparer
|
||||
{
|
||||
private const int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataRestorePreparer).Namespace, nameof(DataRestorePreparer));
|
||||
|
||||
/// <summary>
|
||||
/// A file of the backup to unpack, and where it goes.
|
||||
/// </summary>
|
||||
private sealed record PendingFile(ZipArchiveEntry Entry, DataBackupManifestEntry Listed, string DestinationPath);
|
||||
|
||||
/// <summary>
|
||||
/// Unpacks a backup for the next start.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every file has to match the size and the hash the manifest lists for it: a restore replaces all
|
||||
/// data, so a damaged backup must not get that far. A file the manifest does not list, or one
|
||||
/// which belongs to no element the restore replaces, stays in the backup. A restore prepared
|
||||
/// before is discarded first; one which fails or is cancelled leaves nothing behind.
|
||||
/// </remarks>
|
||||
/// <param name="archivePath">The backup.</param>
|
||||
/// <param name="target">Where the restore goes.</param>
|
||||
/// <param name="progress">Receives how many files are unpacked.</param>
|
||||
/// <param name="token">Cancels the preparation.</param>
|
||||
/// <returns>Whether the restore is prepared, or why not.</returns>
|
||||
public static async Task<DataRestoreResult> PrepareAsync(string archivePath, DataRestoreTarget target, IProgress<DataRestoreProgress>? progress, CancellationToken token)
|
||||
{
|
||||
Discard(target);
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token);
|
||||
if (manifestResult.Manifest is not { } manifest)
|
||||
return new(false, false, manifestResult.Issue);
|
||||
|
||||
var versionIssue = CheckVersions(manifest, target);
|
||||
if (!string.IsNullOrWhiteSpace(versionIssue))
|
||||
return new(false, false, versionIssue);
|
||||
|
||||
var elements = DetermineElements(manifest, target);
|
||||
var files = DetermineFiles(archive, manifest, elements, target);
|
||||
progress?.Report(new(0, files.Count));
|
||||
|
||||
var buffer = new byte[BUFFER_SIZE];
|
||||
for (var index = 0; index < files.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!await UnpackAsync(files[index], buffer, token))
|
||||
{
|
||||
Discard(target);
|
||||
return new(false, false, string.Format(TB("The backup is damaged: the file '{0}' does not match its content list."), files[index].Listed.Name));
|
||||
}
|
||||
|
||||
progress?.Report(new(index + 1, files.Count));
|
||||
}
|
||||
|
||||
var pendingDirectory = PendingDirectory(target.DataDirectory);
|
||||
Directory.CreateDirectory(pendingDirectory);
|
||||
|
||||
var plan = new DataRestorePlan(DataRestoreFormat.PLAN_FORMAT_VERSION, DateTimeOffset.UtcNow, manifest.CreatedAtUtc, manifest.AppVersion, elements);
|
||||
await File.WriteAllBytesAsync(Path.Join(pendingDirectory, DataRestoreFormat.PLAN_FILE_NAME), JsonSerializer.SerializeToUtf8Bytes(plan, DataBackupFormat.JSON_OPTIONS), token);
|
||||
|
||||
// Last of all: from now on, the next start applies the restore.
|
||||
await File.WriteAllTextAsync(Path.Join(pendingDirectory, DataRestoreFormat.READY_FILE_NAME), plan.PreparedAtUtc.ToString("O"), token);
|
||||
return new(true, false, string.Empty);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
Discard(target);
|
||||
return new(false, false, TB("This file is no backup of AI Studio, or it was not written completely."));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Discard(target);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a prepared restore, complete or not, so that the next start leaves everything as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A restore which the runtime has not finished stays: its journal is the only way back to the data
|
||||
/// it replaced, cf. DataRestoreFollowUp.IsRestoreUnfinished.
|
||||
/// </remarks>
|
||||
/// <param name="target">Where the restore was to go.</param>
|
||||
public static void Discard(DataRestoreTarget target)
|
||||
{
|
||||
if (DataRestoreFollowUp.IsRestoreUnfinished(target.DataDirectory))
|
||||
throw new InvalidOperationException("The runtime has not finished the last restore yet, so it cannot be discarded.");
|
||||
|
||||
// First of all: should removing the rest fail halfway, the next start must not apply what is left.
|
||||
var readyPath = Path.Join(PendingDirectory(target.DataDirectory), DataRestoreFormat.READY_FILE_NAME);
|
||||
if (File.Exists(readyPath))
|
||||
File.Delete(readyPath);
|
||||
|
||||
foreach (var pendingDirectory in new[] { PendingDirectory(target.DataDirectory), PendingDirectory(target.ConfigDirectory) })
|
||||
if (Directory.Exists(pendingDirectory))
|
||||
Directory.Delete(pendingDirectory, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a restore is prepared completely and waits for the next start.
|
||||
/// </summary>
|
||||
/// <param name="target">Where the restore goes.</param>
|
||||
/// <returns>True when the next start applies a restore.</returns>
|
||||
public static bool IsPrepared(DataRestoreTarget target) => DataRestoreFollowUp.IsRestorePending(target.DataDirectory);
|
||||
|
||||
private static string PendingDirectory(string directory) => DataRestoreFormat.DirectoryIn(directory, DataRestoreFormat.PENDING_DIRECTORY);
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a backup of a later version of AI Studio. Its settings or its index may have a form
|
||||
/// this version cannot read, and it would refuse to write them, or break them.
|
||||
/// </summary>
|
||||
/// <returns>Why the backup cannot be restored, or an empty string.</returns>
|
||||
private static string CheckVersions(DataBackupManifest manifest, DataRestoreTarget target)
|
||||
{
|
||||
var settingsAreNewer = !Enum.TryParse<SettingsVersion>(manifest.SettingsVersion, out var settingsVersion) || settingsVersion > target.SettingsVersion;
|
||||
var appIsNewer = Version.TryParse(manifest.AppVersion, out var backupAppVersion) && Version.TryParse(target.AppVersion, out var appVersion) && backupAppVersion > appVersion;
|
||||
return settingsAreNewer || appIsNewer
|
||||
? TB("This backup was made by a newer version of AI Studio. Please update AI Studio first.")
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the elements the restore replaces: those of the list of the backup contents which the
|
||||
/// backup holds anything for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An element the backup holds nothing for stays as it is, since nothing tells whether it was empty
|
||||
/// or whether the version of AI Studio which made the backup knew it at all. The indexes are
|
||||
/// replaced only when the backup holds both of them, cf. DataBackupManifest.IncludesDataSourceIndexes.
|
||||
/// </remarks>
|
||||
private static List<DataRestoreElement> DetermineElements(DataBackupManifest manifest, DataRestoreTarget target)
|
||||
{
|
||||
var elements = new List<DataRestoreElement>();
|
||||
foreach (var entry in DataBackupContents.ENTRIES.Where(entry => entry.IsIncluded))
|
||||
{
|
||||
if (entry.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.VECTOR_STORES && !manifest.IncludesDataSourceIndexes)
|
||||
continue;
|
||||
|
||||
var entryName = DataBackupFormat.EntryName(entry.Location, entry.RelativePath);
|
||||
if (!manifest.Entries.Any(listed => DataBackupContents.Covers(entryName, listed.Name)))
|
||||
continue;
|
||||
|
||||
var keep = DataBackupContents.ENTRIES
|
||||
.Where(excluded => !excluded.IsIncluded && excluded.Location == entry.Location && entry.IsFolder && DataBackupContents.Covers(entry.RelativePath, excluded.RelativePath))
|
||||
.Select(excluded => excluded.RelativePath)
|
||||
.Concat(entry.Kind is DataBackupEntryKind.PLUGINS ? target.OrganizationPluginFolders : [])
|
||||
.ToList();
|
||||
|
||||
elements.Add(new(entry.Location, entry.RelativePath, keep));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the files to unpack: those the manifest lists, which belong to an element the restore
|
||||
/// replaces, and which the element does not keep.
|
||||
/// </summary>
|
||||
private static List<PendingFile> DetermineFiles(ZipArchive archive, DataBackupManifest manifest, IReadOnlyList<DataRestoreElement> elements, DataRestoreTarget target)
|
||||
{
|
||||
var listedEntries = manifest.Entries.ToDictionary(listed => listed.Name, StringComparer.Ordinal);
|
||||
var files = new List<PendingFile>();
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var entryName = ZipEntryNames.Normalize(entry.FullName);
|
||||
if (!listedEntries.TryGetValue(entryName, out var listed))
|
||||
continue;
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
var root = DataBackupFormat.EntryName(element.Location, string.Empty);
|
||||
if (!entryName.StartsWith(root, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var relativePath = entryName[root.Length..];
|
||||
if (!DataBackupContents.Covers(element.RelativePath, relativePath) || element.Keep.Any(kept => DataBackupContents.Covers(kept, relativePath)))
|
||||
continue;
|
||||
|
||||
var directory = element.Location is DataBackupLocation.CONFIG_DIRECTORY ? target.ConfigDirectory : target.DataDirectory;
|
||||
files.Add(new(entry, listed, ZipEntryNames.GetDestinationPath(PendingDirectory(directory), relativePath)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpacks one file, and checks its size and hash while it streams through.
|
||||
/// </summary>
|
||||
/// <returns>False when the file does not match the manifest.</returns>
|
||||
private static async Task<bool> UnpackAsync(PendingFile file, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file.DestinationPath)!);
|
||||
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
long size = 0;
|
||||
await using (var source = file.Entry.Open())
|
||||
await using (var destination = new FileStream(file.DestinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, token)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
size += read;
|
||||
}
|
||||
}
|
||||
|
||||
if (size != file.Listed.Size || Convert.ToHexStringLower(hash.GetHashAndReset()) != file.Listed.Sha256)
|
||||
return false;
|
||||
|
||||
// The time of the last change tells, e.g., the order of the chats in a workspace:
|
||||
File.SetLastWriteTime(file.DestinationPath, file.Entry.LastWriteTime.LocalDateTime);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How far the preparation of a restore got.
|
||||
/// </summary>
|
||||
/// <param name="Processed">How many files of the backup are unpacked.</param>
|
||||
/// <param name="Total">How many files there are to unpack, or zero while that is not known yet.</param>
|
||||
public readonly record struct DataRestoreProgress(int Processed, int Total);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How the runtime applied a restore at this start, as it writes it to DataRestoreFormat.RESULT_FILE_NAME.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The points in time stay text: the runtime leaves them empty when it could not read the plan.
|
||||
/// </remarks>
|
||||
/// <param name="Outcome">How the restore ended.</param>
|
||||
/// <param name="Detail">What happened, in English, for the log and for the support.</param>
|
||||
/// <param name="PreparedAtUtc">When the restore was prepared, or an empty string.</param>
|
||||
/// <param name="BackupCreatedAtUtc">When the backup was made, or an empty string.</param>
|
||||
/// <param name="BackupAppVersion">The version of AI Studio which made the backup, or an empty string.</param>
|
||||
/// <param name="FinishedAtUtc">When the runtime finished.</param>
|
||||
public sealed record DataRestoreReport(DataRestoreOutcome Outcome, string Detail, string PreparedAtUtc, string BackupCreatedAtUtc, string BackupAppVersion, string FinishedAtUtc);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// How the preparation of a restore ended.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the restore is prepared and waits for the next start.</param>
|
||||
/// <param name="Cancelled">Whether the user stopped the preparation.</param>
|
||||
/// <param name="Issue">Why the restore cannot be prepared, for the user. Empty when it is prepared.</param>
|
||||
public sealed record DataRestoreResult(bool Success, bool Cancelled, string Issue);
|
||||
@@ -0,0 +1,13 @@
|
||||
using SettingsVersion = AIStudio.Settings.Version;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Where a restore goes, and what it has to fit to.
|
||||
/// </summary>
|
||||
/// <param name="DataDirectory">The data folder.</param>
|
||||
/// <param name="ConfigDirectory">The configuration folder.</param>
|
||||
/// <param name="AppVersion">The version of this AI Studio. A backup of a later version is refused.</param>
|
||||
/// <param name="SettingsVersion">The version of the settings this AI Studio writes. A backup with later settings is refused.</param>
|
||||
/// <param name="OrganizationPluginFolders">The plugins an organization deployed among the user's own ones, cf. DataBackupContents.FindOrganizationPluginsAmongUserPlugins. They stay as they are.</param>
|
||||
public sealed record DataRestoreTarget(string DataDirectory, string ConfigDirectory, string AppVersion, SettingsVersion SettingsVersion, IReadOnlyList<string> OrganizationPluginFolders);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// A secret setting of a tool which the user set up, and which may lack its value after a restore.
|
||||
/// </summary>
|
||||
/// <param name="ToolId">The tool.</param>
|
||||
/// <param name="FieldName">The setting, as the schema of the tool names it.</param>
|
||||
/// <param name="ToolName">The name of the tool, as the user reads it.</param>
|
||||
/// <param name="SettingLabel">The label of the setting, as the user reads it.</param>
|
||||
public sealed record DataRestoreToolSecret(string ToolId, string FieldName, string ToolName, string SettingLabel);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Tells whether the OS keyring of this computer holds a secret.
|
||||
/// </summary>
|
||||
public interface IStoredSecrets
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the API key of a provider is stored.
|
||||
/// </summary>
|
||||
/// <param name="secretId">The provider.</param>
|
||||
/// <param name="storeType">The kind of provider.</param>
|
||||
/// <returns>True when the key is stored.</returns>
|
||||
public Task<bool> HasAPIKeyAsync(ISecretId secretId, SecretStoreType storeType);
|
||||
|
||||
/// <summary>
|
||||
/// Whether another secret is stored, e.g., the password of a data source.
|
||||
/// </summary>
|
||||
/// <param name="secretId">What the secret belongs to.</param>
|
||||
/// <param name="storeType">The kind of secret.</param>
|
||||
/// <returns>True when the secret is stored.</returns>
|
||||
public Task<bool> HasSecretAsync(ISecretId secretId, SecretStoreType storeType);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
namespace AIStudio.Tools.DataBackup;
|
||||
|
||||
/// <summary>
|
||||
/// Asks the runtime whether the OS keyring holds a secret. It only tries, so a missing secret logs no error.
|
||||
/// </summary>
|
||||
/// <param name="rustService">The runtime.</param>
|
||||
public sealed class RustStoredSecrets(RustService rustService) : IStoredSecrets
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasAPIKeyAsync(ISecretId secretId, SecretStoreType storeType) => (await rustService.GetAPIKey(secretId, storeType, isTrying: true)).Success;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasSecretAsync(ISecretId secretId, SecretStoreType storeType) => (await rustService.GetSecret(secretId, storeType, isTrying: true)).Success;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a path below the data directory relative to it, and every other path as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AI Studio keeps files of its own below the data directory: the transcripts of a chat, the copies of
|
||||
/// the tokenizers, and more. A stored path to such a file outlives the computer it was written on. A
|
||||
/// backup brings it to another computer, where the user name, the OS, or the data directory itself may
|
||||
/// differ. Relative to the data directory, the path still names the same file there. In memory, the
|
||||
/// path stays absolute, so the code which works with it never notices.<br/><br/>
|
||||
///
|
||||
/// A relative path is written with forward slashes, which every OS reads. A path to a file of the user
|
||||
/// stays absolute: it lies outside of the data directory, and a backup does not bring that file along.
|
||||
/// A path written before AI Studio stored such paths relative is absolute as well, and is read as it
|
||||
/// is. While the data directory is not known yet, every path stays as it is.
|
||||
/// </remarks>
|
||||
public sealed class DataDirectoryRelativePathConverter : JsonConverter<string>
|
||||
{
|
||||
private const char STORED_SEPARATOR = '/';
|
||||
|
||||
#region Overrides of JsonConverter<string>
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ToAbsolute(reader.GetString() ?? string.Empty, SettingsManager.DataDirectory);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => writer.WriteStringValue(ToStored(value, SettingsManager.DataDirectory));
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Turns a path into the form in which it is stored.
|
||||
/// </summary>
|
||||
/// <param name="path">The absolute path, as AI Studio works with it.</param>
|
||||
/// <param name="dataDirectory">The data directory, or null while it is not known yet.</param>
|
||||
/// <returns>The path relative to the data directory when it lies below it, otherwise the path as it is.</returns>
|
||||
internal static string ToStored(string path, string? dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(dataDirectory) || !Path.IsPathFullyQualified(path) || !PathTools.IsInsideDirectory(dataDirectory, path))
|
||||
return path;
|
||||
|
||||
return Path.GetRelativePath(Path.GetFullPath(dataDirectory), Path.GetFullPath(path)).Replace(Path.DirectorySeparatorChar, STORED_SEPARATOR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a stored path back into the absolute path AI Studio works with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A relative path which leads out of the data directory was not written by AI Studio. Rather than
|
||||
/// resolving it against whatever directory the process runs in, it is read as no path at all.
|
||||
/// </remarks>
|
||||
/// <param name="storedPath">The path as it was stored.</param>
|
||||
/// <param name="dataDirectory">The data directory, or null while it is not known yet.</param>
|
||||
/// <returns>The absolute path.</returns>
|
||||
internal static string ToAbsolute(string storedPath, string? dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedPath) || string.IsNullOrWhiteSpace(dataDirectory) || Path.IsPathRooted(storedPath))
|
||||
return storedPath;
|
||||
|
||||
var path = Path.Join(dataDirectory, storedPath.Replace(STORED_SEPARATOR, Path.DirectorySeparatorChar));
|
||||
return PathTools.IsInsideDirectory(dataDirectory, path) ? path : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ public static class DataSourceRepair
|
||||
/// <returns>True when the rebuild was started.</returns>
|
||||
public static async Task<bool> ConfirmAndRepairAsync(IDialogService dialogService, DataSourceEmbeddingService embeddingService, string dataSourceId, string dataSourceName)
|
||||
{
|
||||
// A repair discards the index, which waits for the end of a pause:
|
||||
if (embeddingService.IsIndexingPaused)
|
||||
{
|
||||
await dialogService.ShowMessageBox(TB("Repair Data Source"), TB("AI Studio is backing up or restoring the indexes of your data sources right now. Please try again once that is done."), TB("Close"));
|
||||
return false;
|
||||
}
|
||||
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(TB("The index of the data source '{0}' cannot be read anymore. Repairing it means building the index from scratch: everything indexed so far is thrown away, and every document of this data source is sent to your embedding provider once more. With a cloud provider, this costs money, and with a large data source it takes a while. Do you want to repair this data source now?"), dataSourceName) },
|
||||
|
||||
@@ -53,4 +53,16 @@ public abstract partial class IndexStoreClient(string name, string path) : Datab
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The number of chunks, or null when the index cannot tell. Null and zero mean different things here.</returns>
|
||||
public abstract Task<long?> GetTotalChunkCountAsync(CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a consistent copy of the index into the given directory, e.g., for a backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pause the indexing first, cf. DataSourceEmbeddingService.PauseIndexingAsync. The copy is
|
||||
/// consistent on its own, but the vector stores copied for the same backup have to fit to it.
|
||||
/// </remarks>
|
||||
/// <param name="targetDirectory">Where to write the copy. A copy written there before is replaced.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The path of the copy.</returns>
|
||||
public abstract Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token);
|
||||
}
|
||||
@@ -67,6 +67,15 @@ public sealed partial class NoIndexStoreClient(string name, string? unavailableR
|
||||
|
||||
public override Task<long?> GetTotalChunkCountAsync(CancellationToken token) => Task.FromResult<long?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Unlike the other methods, this one does not pretend success. A backup without the index would
|
||||
/// still take the vector stores along, and after a restore, they would belong to nothing the index
|
||||
/// knows. Whoever makes the backup decides what to do instead.
|
||||
/// </remarks>
|
||||
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) =>
|
||||
Task.FromException<string>(new InvalidOperationException(unavailableReason ?? "The index of the data sources is not available."));
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
+63
@@ -7,6 +7,7 @@ using AIStudio.Tools.Mail;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tools.Databases.IndexStore;
|
||||
@@ -580,6 +581,68 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
|
||||
return versions.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// VACUUM INTO writes a consistent snapshot of the database, including what the write-ahead log
|
||||
/// still holds, and in WAL mode, reading it holds back no writer.<br/><br/>
|
||||
///
|
||||
/// It runs on connections of their own, although the databases guide has raw SQL go through
|
||||
/// GetDbConnection. The connections of the context share one cache, and a shared cache locks
|
||||
/// whole tables: reading the whole database for the copy would hold back every writer until the
|
||||
/// copy is done. Without pooling, nothing keeps the copy open afterward, so the backup can
|
||||
/// delete it on every OS.
|
||||
/// </remarks>
|
||||
public override async Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token)
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
var targetPath = Path.Join(targetDirectory, DATABASE_FILENAME);
|
||||
|
||||
// VACUUM INTO refuses to write into a file which holds anything:
|
||||
if (File.Exists(targetPath))
|
||||
File.Delete(targetPath);
|
||||
|
||||
try
|
||||
{
|
||||
await using (var connection = new SqliteConnection(BuildBackupConnectionString(this.databasePath, SqliteOpenMode.ReadWrite)))
|
||||
{
|
||||
await connection.OpenAsync(token);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "VACUUM INTO $targetPath;";
|
||||
command.Parameters.AddWithValue("$targetPath", targetPath);
|
||||
await command.ExecuteNonQueryAsync(token);
|
||||
}
|
||||
|
||||
await using (var connection = new SqliteConnection(BuildBackupConnectionString(targetPath, SqliteOpenMode.ReadOnly)))
|
||||
{
|
||||
await connection.OpenAsync(token);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "PRAGMA quick_check;";
|
||||
|
||||
var checkResult = (await command.ExecuteScalarAsync(token))?.ToString();
|
||||
if (checkResult is not "ok")
|
||||
throw new InvalidOperationException($"The copy of the index at '{targetPath}' failed its check: '{checkResult}'.");
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
File.Delete(targetPath);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildBackupConnectionString(string path, SqliteOpenMode mode) => new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = path,
|
||||
Mode = mode,
|
||||
Cache = SqliteCacheMode.Private,
|
||||
Pooling = false,
|
||||
DefaultTimeout = 30,
|
||||
}.ToString();
|
||||
|
||||
private IndexStoreDbContext CreateContext() => new(this.dbContextOptions);
|
||||
|
||||
private static void ApplyDataSource(
|
||||
|
||||
@@ -25,6 +25,9 @@ public sealed class NoVectorStoreClient(string name, string? unavailableReason,
|
||||
public override Task<VectorStoreEnsureResult> EnsureVectorStoreExists(string storeName, string dataSourceName, int vectorSize, CancellationToken token) =>
|
||||
Task.FromException<VectorStoreEnsureResult>(this.CreateUnavailableException());
|
||||
|
||||
public override Task<bool> VectorStoreExists(string storeName, CancellationToken token) =>
|
||||
Task.FromException<bool>(this.CreateUnavailableException());
|
||||
|
||||
public override Task InsertEmbedding(string storeName, IReadOnlyList<VectorStoragePoint> points, CancellationToken token) =>
|
||||
Task.FromException(this.CreateUnavailableException());
|
||||
|
||||
@@ -43,6 +46,12 @@ public sealed class NoVectorStoreClient(string name, string? unavailableReason,
|
||||
public override Task DeleteVectorStore(string storeName, CancellationToken token) =>
|
||||
Task.FromException(this.CreateUnavailableException());
|
||||
|
||||
public override Task<VectorStoreBackupPreparation> PrepareBackupAsync(CancellationToken token) =>
|
||||
Task.FromException<VectorStoreBackupPreparation>(this.CreateUnavailableException());
|
||||
|
||||
public override Task<long> CopyStoreForBackupAsync(string backupId, string storeName, CancellationToken token) =>
|
||||
Task.FromException<long>(this.CreateUnavailableException());
|
||||
|
||||
private InvalidOperationException CreateUnavailableException() =>
|
||||
new(unavailableReason ?? "The vector store is not available.");
|
||||
|
||||
|
||||
+25
-1
@@ -23,12 +23,15 @@ public sealed class QdrantEdgeClientImplementation(
|
||||
private const string DATABASE_NAME = "Qdrant Edge";
|
||||
private const string INFO_PATH = "/system/qdrant-edge/info";
|
||||
private const string ENSURE_PATH = "/system/qdrant-edge/ensure";
|
||||
private const string EXISTS_PATH = "/system/qdrant-edge/exists";
|
||||
private const string INSERT_PATH = "/system/qdrant-edge/insert";
|
||||
private const string SEARCH_PATH = "/system/qdrant-edge/search";
|
||||
private const string DELETE_FILE_PATH = "/system/qdrant-edge/delete-file";
|
||||
private const string OPTIMIZE_PATH = "/system/qdrant-edge/optimize";
|
||||
private const string DELETE_STORE_PATH = "/system/qdrant-edge/delete-store";
|
||||
|
||||
private const string PREPARE_BACKUP_PATH = "/system/qdrant-edge/backup/prepare";
|
||||
private const string COPY_STORE_FOR_BACKUP_PATH = "/system/qdrant-edge/backup/copy";
|
||||
|
||||
private readonly string path = path;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(QdrantEdgeClientImplementation).Namespace, nameof(QdrantEdgeClientImplementation));
|
||||
@@ -122,6 +125,10 @@ public sealed class QdrantEdgeClientImplementation(
|
||||
await rustService.ExecuteDatabaseQuery<EnsureVectorStoreRequest, VectorStoreEnsureResult>(DATABASE_NAME, ENSURE_PATH,
|
||||
new EnsureVectorStoreRequest(storeName, dataSourceName, vectorSize), token) ?? throw new InvalidOperationException("The vector store ensure response was empty.");
|
||||
|
||||
public override async Task<bool> VectorStoreExists(string storeName, CancellationToken token) =>
|
||||
await rustService.ExecuteDatabaseQuery<CheckVectorStoreRequest, bool?>(DATABASE_NAME, EXISTS_PATH,
|
||||
new CheckVectorStoreRequest(storeName), token) ?? throw new InvalidOperationException("The vector store exists response was empty.");
|
||||
|
||||
public override Task InsertEmbedding(string storeName, IReadOnlyList<VectorStoragePoint> points, CancellationToken token) =>
|
||||
rustService.ExecuteDatabaseOperation(DATABASE_NAME, INSERT_PATH, new InsertEmbeddingRequest(storeName, points), token);
|
||||
|
||||
@@ -158,6 +165,14 @@ public sealed class QdrantEdgeClientImplementation(
|
||||
public override Task DeleteVectorStore(string storeName, CancellationToken token) =>
|
||||
rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_STORE_PATH, new DeleteVectorStoreRequest(storeName), token);
|
||||
|
||||
public override async Task<VectorStoreBackupPreparation> PrepareBackupAsync(CancellationToken token) =>
|
||||
await rustService.ExecuteDatabaseQuery<PrepareBackupRequest, VectorStoreBackupPreparation>(DATABASE_NAME, PREPARE_BACKUP_PATH,
|
||||
new PrepareBackupRequest(), token) ?? throw new InvalidOperationException("The response preparing the backup of the vector stores was empty.");
|
||||
|
||||
public override async Task<long> CopyStoreForBackupAsync(string backupId, string storeName, CancellationToken token) =>
|
||||
await rustService.ExecuteDatabaseQuery<CopyStoreForBackupRequest, long?>(DATABASE_NAME, COPY_STORE_FOR_BACKUP_PATH,
|
||||
new CopyStoreForBackupRequest(backupId, storeName), token) ?? throw new InvalidOperationException("The response copying a vector store for the backup was empty.");
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
@@ -172,6 +187,8 @@ public sealed class QdrantEdgeClientImplementation(
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
private sealed record EnsureVectorStoreRequest(string StoreName, string DataSourceName, int VectorSize);
|
||||
|
||||
private sealed record CheckVectorStoreRequest(string StoreName);
|
||||
|
||||
private sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList<VectorStoragePoint> Points);
|
||||
|
||||
/// <param name="PointIds">The only points the search may return, or null for the whole store.</param>
|
||||
@@ -182,5 +199,12 @@ public sealed class QdrantEdgeClientImplementation(
|
||||
private sealed record OptimizeVectorStoreRequest(string StoreName);
|
||||
|
||||
private sealed record DeleteVectorStoreRequest(string StoreName);
|
||||
|
||||
/// <summary>
|
||||
/// Preparing a backup needs nothing but the request itself.
|
||||
/// </summary>
|
||||
private sealed record PrepareBackupRequest;
|
||||
|
||||
private sealed record CopyStoreForBackupRequest(string BackupId, string StoreName);
|
||||
// ReSharper restore NotAccessedPositionalProperty.Local
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.Databases.VectorStore;
|
||||
|
||||
/// <summary>
|
||||
/// What a backup needs to copy the stores, one at a time.
|
||||
/// </summary>
|
||||
/// <param name="BackupId">Names the backup when its stores are copied.</param>
|
||||
/// <param name="StagingPath">The directory of the backup's copies. Below it, every copy has the place it has below the data directory. The backup removes it once it is written.</param>
|
||||
/// <param name="Stores">Every store which was created completely.</param>
|
||||
public sealed record VectorStoreBackupPreparation(string BackupId, string StagingPath, IReadOnlyList<VectorStoreBackupStore> Stores);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Databases.VectorStore;
|
||||
|
||||
/// <summary>
|
||||
/// A store which a backup takes along.
|
||||
/// </summary>
|
||||
/// <param name="StoreName">The name of the store.</param>
|
||||
/// <param name="Bytes">How large the store is on the disk.</param>
|
||||
public sealed record VectorStoreBackupStore(string StoreName, long Bytes);
|
||||
@@ -4,6 +4,14 @@ public abstract class VectorStoreClient(string name, string path): DatabaseClien
|
||||
{
|
||||
public abstract Task<VectorStoreEnsureResult> EnsureVectorStoreExists(string storeName, string dataSourceName, int vectorSize, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the store was created completely, without creating or opening it.
|
||||
/// </summary>
|
||||
/// <param name="storeName">The name of the store.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>True when the store exists.</returns>
|
||||
public abstract Task<bool> VectorStoreExists(string storeName, CancellationToken token);
|
||||
|
||||
public abstract Task InsertEmbedding(string storeName, IReadOnlyList<VectorStoragePoint> points, CancellationToken token);
|
||||
|
||||
public abstract Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token);
|
||||
@@ -24,4 +32,29 @@ public abstract class VectorStoreClient(string name, string path): DatabaseClien
|
||||
public abstract Task OptimizeVectorStore(string storeName, CancellationToken token);
|
||||
|
||||
public abstract Task DeleteVectorStore(string storeName, CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Prepares a backup of the stores: lists them, checks that their copies fit on the disk, and
|
||||
/// creates the directory for the copies below the data directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pause the indexing first, cf. DataSourceEmbeddingService.PauseIndexingAsync, so that no store
|
||||
/// changes until all of them are copied.
|
||||
/// </remarks>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The backup, with every store to copy.</returns>
|
||||
public abstract Task<VectorStoreBackupPreparation> PrepareBackupAsync(CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Copies one store into the directory of a prepared backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The store is unloaded for the copy and loads again with the next request. Requests for any
|
||||
/// store wait while one is copied.
|
||||
/// </remarks>
|
||||
/// <param name="backupId">The backup, as PrepareBackupAsync named it.</param>
|
||||
/// <param name="storeName">The name of the store.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>How many bytes were copied. Zero when the store was not created completely.</returns>
|
||||
public abstract Task<long> CopyStoreForBackupAsync(string backupId, string storeName, CancellationToken token);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 COMPARISON, for path-keyed lookups.
|
||||
/// </summary>
|
||||
public static readonly StringComparer COMPARER = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a path lies below the given directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The directory itself does not count as lying below itself. A path which cannot be resolved
|
||||
/// counts as lying elsewhere.
|
||||
/// </remarks>
|
||||
/// <param name="directory">The directory.</param>
|
||||
/// <param name="path">The path to check.</param>
|
||||
/// <returns>Whether the path lies below the directory.</returns>
|
||||
public 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, COMPARISON);
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ Plugins can configure:
|
||||
|
||||
Configuration plugins provide three kinds of values:
|
||||
- **Managed settings:** simple values such as booleans, numbers, strings, enums, lists, or sets handled through `ManagedConfiguration`. These values may be locked or used as organization defaults. Which configuration plugin owns a locked setting is persisted in `Data.ManagedLockedConfigurations`, and organization defaults are tracked in `Data.ManagedEditableDefaults`. Both are cleaned up generically by `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` when the owning plugin is gone. The value a setting had before a configuration plugin took it over is kept in `Data.ManagedUserValueSnapshots` and restored by that same clean-up, so removing a plugin hands the user's own value back instead of the app default.
|
||||
- **Managed configuration objects:** complex Lua tables that are persisted into `SettingsManager.ConfigurationData`, implement `IConfigurationObject`, and are cleaned up through `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`. Examples include providers, profiles, chat templates, data sources, and document analysis policies.
|
||||
- **Managed configuration objects:** complex Lua tables that are persisted into `SettingsManager.ConfigurationData`, implement `IConfigurationObject`, and are described by a `PluginConfigurationObjectDescriptor` in `PluginConfigurationObjectDescriptors`. Reading them from a plugin and cleaning up the left-over ones go through these descriptors. Examples include providers, profiles, chat templates, data sources, and document analysis policies.
|
||||
- **Live plugin content:** complex Lua tables that implement `ILivePluginContent` and are read live from running plugins instead of being persisted to `ConfigurationData`. Examples include `MANDATORY_INFOS` and `INTRODUCTIONS`. If live plugin content creates persistent side data, add a dedicated cleanup path for that side data, like mandatory-info acceptances.
|
||||
|
||||
When adding configuration plugin capabilities:
|
||||
- For managed settings, update the corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)` and process the setting in `PluginConfiguration.TryProcessConfiguration`. Cleaning up the setting when its configuration plugin was removed needs no extra step: `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` iterates all registered settings. Do not add per-setting cleanup calls to `PluginFactory.Loading.LoadAll`.
|
||||
- For managed configuration objects, update `PluginConfigurationObject.cs` and `PluginConfigurationObjectType.cs`, persist them in the appropriate `ConfigurationData` collection, and add cleanup via `PluginConfigurationObject.CleanLeftOverConfigurationObjects(...)`.
|
||||
- For managed configuration objects, add the type to `PluginConfigurationObjectType.cs`, persist the objects in the appropriate `ConfigurationData` collection, and add a descriptor to `PluginConfigurationObjectDescriptors` and its list `ALL`: its Lua table, its parser, the collection, the next number, and where secrets are kept. Parsing and cleanup need no extra call; `PluginConfigurationObjectDescriptorsTests` fails for a type without a descriptor.
|
||||
- For live plugin content, add a data type implementing `ILivePluginContent`, parse it in `PluginConfiguration`, expose it through `PluginFactory`, and add any required cleanup only for persistent side data.
|
||||
- Always document the new capability in `app/MindWork AI Studio/Plugins/configuration/plugin.lua`.
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a type of configuration object which derives from ConfigurationBaseObject.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of the configuration objects.</param>
|
||||
/// <param name="luaTableName">The Lua table in which a configuration plugin defines these objects.</param>
|
||||
/// <param name="selection">Where the settings keep these objects.</param>
|
||||
/// <param name="nextNumSelection">The number which the next of these objects gets.</param>
|
||||
/// <param name="parser">Reads one of these objects from its Lua table.</param>
|
||||
/// <param name="secretStoreType">Where the API keys of these objects are kept, or null for objects without one.</param>
|
||||
/// <typeparam name="TClass">The class of the configuration objects.</typeparam>
|
||||
public sealed class ConfigurationBaseObjectDescriptor<TClass>(PluginConfigurationObjectType type, string luaTableName, Expression<Func<Data, List<TClass>>> selection, Expression<Func<Data, uint>> nextNumSelection, ConfigurationObjectParser parser, SecretStoreType? secretStoreType = null) : PluginConfigurationObjectDescriptor where TClass : ConfigurationBaseObject
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override PluginConfigurationObjectType Type => type;
|
||||
|
||||
/// <summary>
|
||||
/// The Lua table in which a configuration plugin defines these objects.
|
||||
/// </summary>
|
||||
public string LuaTableName => luaTableName;
|
||||
|
||||
/// <summary>
|
||||
/// Where the settings keep these objects.
|
||||
/// </summary>
|
||||
public Expression<Func<Data, List<TClass>>> Selection => selection;
|
||||
|
||||
/// <summary>
|
||||
/// The number which the next of these objects gets.
|
||||
/// </summary>
|
||||
public Expression<Func<Data, uint>> NextNumSelection => nextNumSelection;
|
||||
|
||||
/// <summary>
|
||||
/// Reads one of these objects from its Lua table.
|
||||
/// </summary>
|
||||
public ConfigurationObjectParser Parser => parser;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryParse(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParse(this, mainTable, configPluginId, configObjects, dryRun, pluginPath);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> CleanLeftOverAsync(IList<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds, IReadOnlySet<Guid> notStartedConfigPluginIds, IList<PluginConfigurationObject> configObjectList) => PluginConfigurationObject.CleanLeftOverConfigurationObjects(type, selection, availablePlugins, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, secretStoreType);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int RemovePluginObjects(Data settings) => selection.Compile()(settings).RemoveAll(configObject => configObject.IsEnterpriseConfiguration);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Reads one configuration object from the Lua table in which a configuration plugin defines it.
|
||||
/// </summary>
|
||||
/// <param name="index">The position of the entry within its Lua table, which the log names.</param>
|
||||
/// <param name="table">The entry to read.</param>
|
||||
/// <param name="configPluginId">The configuration plugin which defines the object.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin, which paths in the entry are relative to.</param>
|
||||
/// <param name="dryRun">Whether the plugin is only checked, e.g., before an import. Nothing must be stored then.</param>
|
||||
/// <param name="configObject">The configuration object read from the entry.</param>
|
||||
/// <returns>True when the entry defines a valid configuration object.</returns>
|
||||
public delegate bool ConfigurationObjectParser(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject);
|
||||
@@ -0,0 +1,32 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the data sources which configuration plugins provide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings keep every data source in a list of IDataSource, and a data source of a configuration
|
||||
/// plugin is a record struct rather than a ConfigurationBaseObject. That is why data sources are read
|
||||
/// in a way of their own.
|
||||
/// </remarks>
|
||||
public sealed class DataSourceConfigurationObjectDescriptor : PluginConfigurationObjectDescriptor
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override PluginConfigurationObjectType Type => PluginConfigurationObjectType.DATA_SOURCE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryParse(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParseDataSources(mainTable, configPluginId, configObjects, dryRun);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The secret of a data source is not necessarily an API key, e.g., it is a password, so it is
|
||||
/// deleted as a secret.
|
||||
/// </remarks>
|
||||
public override Task<bool> CleanLeftOverAsync(IList<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds, IReadOnlySet<Guid> notStartedConfigPluginIds, IList<PluginConfigurationObject> configObjectList) => PluginConfigurationObject.CleanLeftOverConfigurationObjects(this.Type, x => x.DataSources, availablePlugins, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int RemovePluginObjects(Data settings) => settings.DataSources.RemoveAll(dataSource => dataSource.IsEnterpriseConfiguration);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed record NoConfigurationObject : ConfigurationBaseObject
|
||||
{
|
||||
public static readonly NoConfigurationObject INSTANCE = new();
|
||||
|
||||
private NoConfigurationObject()
|
||||
{
|
||||
this.Id = Guid.Empty.ToString();
|
||||
this.Name = "No Configuration";
|
||||
}
|
||||
|
||||
#region Overrides of ConfigurationBaseObject
|
||||
|
||||
public override string Id { get; init; }
|
||||
|
||||
public override uint Num { get; init; }
|
||||
|
||||
public override string Name { get; init; }
|
||||
|
||||
public override bool IsEnterpriseConfiguration { get; init; }
|
||||
|
||||
public override Guid EnterpriseConfigurationPluginId { get; init; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -22,16 +22,12 @@ public static class PluginArchive
|
||||
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
|
||||
var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory);
|
||||
if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
|
||||
destinationDirectoryFullPath += Path.DirectorySeparatorChar;
|
||||
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var normalizedEntryName = NormalizeEntryName(entry.FullName);
|
||||
var destinationPath = GetEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName);
|
||||
var normalizedEntryName = ZipEntryNames.Normalize(entry.FullName);
|
||||
var destinationPath = ZipEntryNames.GetDestinationPath(destinationDirectory, normalizedEntryName);
|
||||
|
||||
if (normalizedEntryName.EndsWith('/'))
|
||||
if (normalizedEntryName.EndsWith(ZipEntryNames.SEPARATOR))
|
||||
{
|
||||
if (entry.Length != 0)
|
||||
throw new InvalidDataException($"The plugin archive contains a directory entry with data: '{entry.FullName}'.");
|
||||
@@ -44,37 +40,4 @@ public static class PluginArchive
|
||||
entry.ExtractToFile(destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeEntryName(string entryName)
|
||||
{
|
||||
var normalizedEntryName = entryName.Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(normalizedEntryName))
|
||||
throw new InvalidDataException("The plugin archive contains an empty entry name.");
|
||||
|
||||
if (normalizedEntryName.Contains('\0'))
|
||||
throw new InvalidDataException($"The plugin archive contains an invalid entry name: '{entryName}'.");
|
||||
|
||||
if (normalizedEntryName.StartsWith('/'))
|
||||
throw new InvalidDataException($"The plugin archive contains a rooted entry name: '{entryName}'.");
|
||||
|
||||
if (normalizedEntryName is [_, ':', ..])
|
||||
throw new InvalidDataException($"The plugin archive contains a drive-qualified entry name: '{entryName}'.");
|
||||
|
||||
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or ".."))
|
||||
throw new InvalidDataException($"The plugin archive contains an unsafe entry name: '{entryName}'.");
|
||||
|
||||
return normalizedEntryName;
|
||||
}
|
||||
|
||||
private static string GetEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName)
|
||||
{
|
||||
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var relativePath = Path.Combine(pathSegments);
|
||||
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath));
|
||||
if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal))
|
||||
throw new InvalidDataException($"The plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'.");
|
||||
|
||||
return destinationPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: allow the user to share or export plugins?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to back up the data of AI Studio?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToBackUpData, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to restore the data of AI Studio from a backup?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToRestoreData, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: show administration settings?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -349,26 +355,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: enterprise-managed approvals for assistant plugins
|
||||
this.TryProcessEnterpriseApprovedAssistantPlugins(settingsTable, dryRun);
|
||||
|
||||
// Handle configured LLM providers:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
|
||||
|
||||
// Handle configured transcription providers:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, x => x.NextTranscriptionNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
|
||||
|
||||
// Handle configured embedding providers:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
|
||||
|
||||
// Handle configured chat templates:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
|
||||
|
||||
// Handle configured data sources:
|
||||
PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun);
|
||||
|
||||
// Handle configured profiles:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun);
|
||||
|
||||
// Handle configured document analysis policies:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, x => x.NextDocumentAnalysisPolicyNum, mainTable, this.Id, ref this.configObjects, dryRun);
|
||||
// Handle configured objects such as LLM providers, chat templates, or data sources:
|
||||
foreach (var descriptor in PluginConfigurationObjectDescriptors.ALL)
|
||||
descriptor.TryParse(mainTable, this.Id, this.configObjects, dryRun, this.PluginPath);
|
||||
|
||||
// Handle configured mandatory infos:
|
||||
this.TryReadMandatoryInfos(mainTable);
|
||||
|
||||
@@ -125,53 +125,22 @@ public sealed record PluginConfigurationObject
|
||||
/// provided list with results.
|
||||
/// </summary>
|
||||
/// <typeparam name="TClass">The type of configuration object to parse, which must
|
||||
/// inherit from <see cref="ConfigurationBaseObject"/>.</typeparam>
|
||||
/// <param name="configObjectType">The type of configuration object to process, as specified
|
||||
/// in <see cref="PluginConfigurationObjectType"/>.</param>
|
||||
/// <param name="configObjectSelection">An expression to retrieve existing configuration objects from
|
||||
/// the main configuration data.</param>
|
||||
/// <param name="nextConfigObjectNumSelection">An expression to retrieve the next available configuration
|
||||
/// object number from the main configuration data.</param>
|
||||
/// inherit from ConfigurationBaseObject.</typeparam>
|
||||
/// <param name="descriptor">Describes the type of configuration object: its Lua table, how to
|
||||
/// read one entry, and where the settings keep these objects.</param>
|
||||
/// <param name="mainTable">The Lua table containing entries to parse into configuration objects.</param>
|
||||
/// <param name="configPluginId">The unique identifier of the plugin associated with the configuration
|
||||
/// objects being parsed.</param>
|
||||
/// <param name="configObjects">The list to populate with the parsed configuration objects.
|
||||
/// This parameter is passed by reference.</param>
|
||||
/// <param name="configObjects">The list to populate with the parsed configuration objects.</param>
|
||||
/// <param name="dryRun">Specifies whether to perform the operation as a dry run, where changes
|
||||
/// are not persisted.</param>
|
||||
/// <param name="pluginPath">An optional parameter specifying the file path of the plugin, used for relative paths in the Lua table.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin, used for relative paths in the Lua table.</param>
|
||||
/// <returns>Returns true if parsing succeeds and configuration objects are added
|
||||
/// to the list; otherwise, false.</returns>
|
||||
public static bool TryParse<TClass>(
|
||||
PluginConfigurationObjectType configObjectType,
|
||||
Expression<Func<Data, List<TClass>>> configObjectSelection,
|
||||
Expression<Func<Data, uint>> nextConfigObjectNumSelection,
|
||||
LuaTable mainTable,
|
||||
Guid configPluginId,
|
||||
ref List<PluginConfigurationObject> configObjects,
|
||||
bool dryRun,
|
||||
string pluginPath = ""
|
||||
) where TClass : ConfigurationBaseObject
|
||||
public static bool TryParse<TClass>(ConfigurationBaseObjectDescriptor<TClass> descriptor, LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun, string pluginPath) where TClass : ConfigurationBaseObject
|
||||
{
|
||||
var luaTableName = configObjectType switch
|
||||
{
|
||||
PluginConfigurationObjectType.LLM_PROVIDER => "LLM_PROVIDERS",
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE => "CHAT_TEMPLATES",
|
||||
PluginConfigurationObjectType.DATA_SOURCE => "DATA_SOURCES",
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER => "EMBEDDING_PROVIDERS",
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => "TRANSCRIPTION_PROVIDERS",
|
||||
PluginConfigurationObjectType.PROFILE => "PROFILES",
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => "DOCUMENT_ANALYSIS_POLICIES",
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (luaTableName is null)
|
||||
{
|
||||
LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var configObjectType = descriptor.Type;
|
||||
var luaTableName = descriptor.LuaTableName;
|
||||
if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
|
||||
{
|
||||
LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId);
|
||||
@@ -179,7 +148,7 @@ public sealed record PluginConfigurationObject
|
||||
}
|
||||
|
||||
var localSettingsManager = SettingsManagerAccess;
|
||||
var storedObjects = configObjectSelection.Compile()(localSettingsManager.ConfigurationData);
|
||||
var storedObjects = descriptor.Selection.Compile()(localSettingsManager.ConfigurationData);
|
||||
var numberObjects = luaTable.ArrayLength;
|
||||
ThreadSafeRandom? random = null;
|
||||
for (var i = 1; i <= numberObjects; i++)
|
||||
@@ -191,19 +160,7 @@ public sealed record PluginConfigurationObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var (wasParsingSuccessful, configObject) = configObjectType switch
|
||||
{
|
||||
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject),
|
||||
PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject),
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => (DataDocumentAnalysisPolicy.TryProcessConfiguration(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject is DataDocumentAnalysisPolicy, configurationObject),
|
||||
|
||||
_ => (false, NoConfigurationObject.INSTANCE)
|
||||
};
|
||||
|
||||
if (wasParsingSuccessful)
|
||||
if (descriptor.Parser(i, luaObjectTable, configPluginId, pluginPath, dryRun, out var configObject))
|
||||
{
|
||||
// Store it in the config object list:
|
||||
configObjects.Add(new()
|
||||
@@ -236,7 +193,7 @@ public sealed record PluginConfigurationObject
|
||||
else
|
||||
{
|
||||
configObject = KeepSynchronizedTokenizer(configObject, null);
|
||||
if (nextConfigObjectNumSelection.TryIncrement(localSettingsManager.ConfigurationData, IncrementType.POST) is { Success: true, UpdatedValue: var nextNum })
|
||||
if (descriptor.NextNumSelection.TryIncrement(localSettingsManager.ConfigurationData, IncrementType.POST) is { Success: true, UpdatedValue: var nextNum })
|
||||
{
|
||||
// Case: Increment the next number was successful
|
||||
configObject = configObject with { Num = nextNum };
|
||||
@@ -330,11 +287,7 @@ public sealed record PluginConfigurationObject
|
||||
/// <param name="configObjects">The list to populate with the parsed configuration objects.</param>
|
||||
/// <param name="dryRun">Specifies whether to perform the operation as a dry run.</param>
|
||||
/// <returns>True if the table was present and processed; otherwise false.</returns>
|
||||
public static bool TryParseDataSources(
|
||||
LuaTable mainTable,
|
||||
Guid configPluginId,
|
||||
ref List<PluginConfigurationObject> configObjects,
|
||||
bool dryRun)
|
||||
public static bool TryParseDataSources(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun)
|
||||
{
|
||||
const string LUA_TABLE_NAME = "DATA_SOURCES";
|
||||
if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
|
||||
@@ -698,16 +651,6 @@ public sealed record PluginConfigurationObject
|
||||
|
||||
var fullPluginPath = Path.GetFullPath(pluginPath);
|
||||
var candidatePath = Path.GetFullPath(Path.Combine(fullPluginPath, configuredTokenizerPath));
|
||||
|
||||
if (candidatePath.Equals(fullPluginPath, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var pluginPrefix = fullPluginPath.EndsWith(Path.DirectorySeparatorChar)
|
||||
? fullPluginPath
|
||||
: fullPluginPath + Path.DirectorySeparatorChar;
|
||||
|
||||
return candidatePath.StartsWith(pluginPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? candidatePath
|
||||
: null;
|
||||
return PathTools.IsInsideDirectory(fullPluginPath, candidatePath) ? candidatePath : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Describes one type of configuration object which configuration plugins provide, such as LLM
|
||||
/// providers or chat templates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whatever treats every type of configuration object alike goes through these descriptors, so that
|
||||
/// none of these places can forget a type: reading the objects from a configuration plugin, removing
|
||||
/// those a plugin no longer defines, and leaving all of them out of a backup. A new type of
|
||||
/// configuration object needs a descriptor in PluginConfigurationObjectDescriptors, and every such place
|
||||
/// handles it.
|
||||
/// </remarks>
|
||||
public abstract class PluginConfigurationObjectDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the configuration objects.
|
||||
/// </summary>
|
||||
public abstract PluginConfigurationObjectType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads the configuration objects of this type which a configuration plugin defines, and stores
|
||||
/// them in the settings unless the plugin is only checked.
|
||||
/// </summary>
|
||||
/// <param name="mainTable">The main table of the configuration plugin.</param>
|
||||
/// <param name="configPluginId">The configuration plugin.</param>
|
||||
/// <param name="configObjects">The list to which every object read is added.</param>
|
||||
/// <param name="dryRun">Whether the plugin is only checked, e.g., before an import. Nothing is stored then.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin, which paths in the objects are relative to.</param>
|
||||
/// <returns>True when the plugin defines a table for this type.</returns>
|
||||
public abstract bool TryParse(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun, string pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the configuration objects of this type which belong to a configuration plugin that is
|
||||
/// gone or no longer defines them, along with their secrets and tokenizers.
|
||||
/// </summary>
|
||||
/// <param name="availablePlugins">The plugins AI Studio found.</param>
|
||||
/// <param name="deployedEnterpriseConfigPluginIds">The configuration plugins an organization deployed, including those which could not be loaded.</param>
|
||||
/// <param name="notStartedConfigPluginIds">The configuration plugins which were loaded, but did not start.</param>
|
||||
/// <param name="configObjectList">The configuration objects all running configuration plugins define.</param>
|
||||
/// <returns>True when the settings changed.</returns>
|
||||
public abstract Task<bool> CleanLeftOverAsync(IList<IAvailablePlugin> availablePlugins, IReadOnlySet<Guid> deployedEnterpriseConfigPluginIds, IReadOnlySet<Guid> notStartedConfigPluginIds, IList<PluginConfigurationObject> configObjectList);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the configuration objects of this type which a configuration plugin manages.
|
||||
/// </summary>
|
||||
/// <param name="settings">The settings to change, e.g., a copy for a backup. Never the settings AI Studio runs with.</param>
|
||||
/// <returns>How many objects were removed.</returns>
|
||||
public abstract int RemovePluginObjects(Data settings);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// The descriptors of every type of configuration object which configuration plugins provide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A new type of configuration object gets its descriptor here and joins ALL. Reading the objects from
|
||||
/// a configuration plugin and removing the left-over ones then handle it, and a test checks that no
|
||||
/// type of PluginConfigurationObjectType is missing.
|
||||
/// </remarks>
|
||||
public static class PluginConfigurationObjectDescriptors
|
||||
{
|
||||
public static readonly ConfigurationBaseObjectDescriptor<Settings.Provider> LLM_PROVIDER = new(PluginConfigurationObjectType.LLM_PROVIDER, "LLM_PROVIDERS", x => x.Providers, x => x.NextProviderNum, ParseLLMProvider, SecretStoreType.LLM_PROVIDER);
|
||||
|
||||
public static readonly ConfigurationBaseObjectDescriptor<TranscriptionProvider> TRANSCRIPTION_PROVIDER = new(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, "TRANSCRIPTION_PROVIDERS", x => x.TranscriptionProviders, x => x.NextTranscriptionNum, ParseTranscriptionProvider, SecretStoreType.TRANSCRIPTION_PROVIDER);
|
||||
|
||||
public static readonly ConfigurationBaseObjectDescriptor<EmbeddingProvider> EMBEDDING_PROVIDER = new(PluginConfigurationObjectType.EMBEDDING_PROVIDER, "EMBEDDING_PROVIDERS", x => x.EmbeddingProviders, x => x.NextEmbeddingNum, ParseEmbeddingProvider, SecretStoreType.EMBEDDING_PROVIDER);
|
||||
|
||||
public static readonly ConfigurationBaseObjectDescriptor<ChatTemplate> CHAT_TEMPLATE = new(PluginConfigurationObjectType.CHAT_TEMPLATE, "CHAT_TEMPLATES", x => x.ChatTemplates, x => x.NextChatTemplateNum, ParseChatTemplate);
|
||||
|
||||
public static readonly DataSourceConfigurationObjectDescriptor DATA_SOURCE = new();
|
||||
|
||||
public static readonly ConfigurationBaseObjectDescriptor<Profile> PROFILE = new(PluginConfigurationObjectType.PROFILE, "PROFILES", x => x.Profiles, x => x.NextProfileNum, ParseProfile);
|
||||
|
||||
public static readonly ConfigurationBaseObjectDescriptor<DataDocumentAnalysisPolicy> DOCUMENT_ANALYSIS_POLICY = new(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, "DOCUMENT_ANALYSIS_POLICIES", x => x.DocumentAnalysis.Policies, x => x.NextDocumentAnalysisPolicyNum, ParseDocumentAnalysisPolicy);
|
||||
|
||||
/// <summary>
|
||||
/// Every descriptor, in the order in which configuration plugins are read.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<PluginConfigurationObjectDescriptor> ALL =
|
||||
[
|
||||
LLM_PROVIDER,
|
||||
TRANSCRIPTION_PROVIDER,
|
||||
EMBEDDING_PROVIDER,
|
||||
CHAT_TEMPLATE,
|
||||
DATA_SOURCE,
|
||||
PROFILE,
|
||||
DOCUMENT_ANALYSIS_POLICY,
|
||||
];
|
||||
|
||||
private static bool ParseLLMProvider(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
Settings.Provider.TryParseProviderTable(index, table, configPluginId, pluginPath, dryRun, out configObject) && configObject != Settings.Provider.NONE;
|
||||
|
||||
private static bool ParseTranscriptionProvider(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
TranscriptionProvider.TryParseTranscriptionProviderTable(index, table, configPluginId, pluginPath, dryRun, out configObject) && configObject != TranscriptionProvider.NONE;
|
||||
|
||||
private static bool ParseEmbeddingProvider(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
EmbeddingProvider.TryParseEmbeddingProviderTable(index, table, configPluginId, pluginPath, dryRun, out configObject) && configObject != EmbeddingProvider.NONE;
|
||||
|
||||
private static bool ParseChatTemplate(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
ChatTemplate.TryParseChatTemplateTable(index, table, configPluginId, pluginPath, out configObject) && configObject != ChatTemplate.NO_CHAT_TEMPLATE;
|
||||
|
||||
private static bool ParseProfile(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
Profile.TryParseProfileTable(index, table, configPluginId, out configObject) && configObject != Profile.NO_PROFILE;
|
||||
|
||||
private static bool ParseDocumentAnalysisPolicy(int index, LuaTable table, Guid configPluginId, string pluginPath, bool dryRun, out ConfigurationBaseObject configObject) =>
|
||||
DataDocumentAnalysisPolicy.TryProcessConfiguration(index, table, configPluginId, out configObject) && configObject is DataDocumentAnalysisPolicy;
|
||||
}
|
||||
@@ -14,7 +14,19 @@ public static partial class PluginFactory
|
||||
{
|
||||
private static readonly List<IAvailablePlugin> AVAILABLE_PLUGINS = [];
|
||||
private static readonly SemaphoreSlim PLUGIN_LOAD_SEMAPHORE = new(1, 1);
|
||||
|
||||
private static readonly TaskCompletionSource FIRST_LOAD_COMPLETED = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Completes once LoadAll got through for the first time, the clean-up of the configuration included.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Until then, the settings may lack whatever the configuration of an organization provides, such
|
||||
/// as its embedding providers: on a new computer, or after restoring a backup, which leaves them
|
||||
/// out. Whoever acts on such objects while AI Studio starts waits for this, or takes them for
|
||||
/// objects which are gone. It never completes when loading failed, so wait with a limit.
|
||||
/// </remarks>
|
||||
public static Task FirstLoadCompleted => FIRST_LOAD_COMPLETED.Task;
|
||||
|
||||
/// <summary>
|
||||
/// How long a single plugin may take to load, and again to start.
|
||||
/// </summary>
|
||||
@@ -303,33 +315,10 @@ public static partial class PluginFactory
|
||||
|
||||
var wasConfigurationChanged = hasStartedConfigurationPlugins;
|
||||
|
||||
// Check LLM providers:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check transcription providers:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check embedding providers:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check data sources:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check chat templates:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check profiles:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check document analysis policies:
|
||||
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
// Check configured objects such as LLM providers, chat templates, or data sources:
|
||||
foreach (var descriptor in PluginConfigurationObjectDescriptors.ALL)
|
||||
if(await descriptor.CleanLeftOverAsync(AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, notStartedConfigPluginIds, configObjectList))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
//
|
||||
// Check left-over mandatory info acceptances. The mandatory infos come from the running
|
||||
@@ -372,6 +361,8 @@ public static partial class PluginFactory
|
||||
await SettingsManagerAccess.StoreSettings();
|
||||
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
|
||||
FIRST_LOAD_COMPLETED.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record AppRestartResponse(bool Success, string ErrorMessage);
|
||||
@@ -1,3 +1,4 @@
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
// 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 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 BACKUP_ARCHIVE = FileTypeFilter.Leaf(TB("AI Studio backup"), DataBackupFormat.FILE_EXTENSION.TrimStart('.'), "zip");
|
||||
|
||||
/// <summary>
|
||||
/// The file types AI Studio converts using Pandoc.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
using AIStudio.Tools.Databases;
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Databases.VectorStore;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.Metadata;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Backs up all data of AI Studio into one file, one backup at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The backup runs here rather than in the component which started it, so leaving the page does not
|
||||
/// stop it, and a schedule can start it later on as well. The components only watch: they read the
|
||||
/// state and hear about every change through StateChanged.<br/><br/>
|
||||
///
|
||||
/// The service enforces what the organization allows on its own, since a component may show an
|
||||
/// outdated state, and a schedule has no component at all.
|
||||
/// </remarks>
|
||||
public sealed class DataBackupService(ILogger<DataBackupService> logger, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService, DatabaseClientProvider databaseClientProvider, AIJobService aiJobService, MediaTranscriptionService mediaTranscriptionService)
|
||||
{
|
||||
private const int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// ERROR_FILE_TOO_LARGE of Windows, as an HRESULT.
|
||||
/// </summary>
|
||||
private const int WINDOWS_FILE_TOO_LARGE = unchecked((int)0x800700DF);
|
||||
|
||||
/// <summary>
|
||||
/// ERROR_FILE_SYSTEM_LIMITATION of Windows, as an HRESULT.
|
||||
/// </summary>
|
||||
private const int WINDOWS_FILE_SYSTEM_LIMITATION = unchecked((int)0x80070299);
|
||||
|
||||
/// <summary>
|
||||
/// EFBIG of Linux and macOS, which .NET passes on as the HResult.
|
||||
/// </summary>
|
||||
private const int UNIX_FILE_TOO_LARGE = 27;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataBackupService).Namespace, nameof(DataBackupService));
|
||||
|
||||
private readonly Lock stateLock = new();
|
||||
|
||||
private CancellationTokenSource? running;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the state changes: a backup starts, makes progress, or ends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It fires on whatever thread the backup runs. A component hands it over to its own context.
|
||||
/// </remarks>
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a backup runs.
|
||||
/// </summary>
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return this.running is not null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far the running backup got.
|
||||
/// </summary>
|
||||
public DataBackupProgress Progress { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the last backup ended, or null when none ended yet.
|
||||
/// </summary>
|
||||
public DataBackupResult? LastResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Backs up all data into the given file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The file is written in place; a file which was there before is replaced. A backup which fails
|
||||
/// or is cancelled removes what it wrote.
|
||||
/// </remarks>
|
||||
/// <param name="archivePath">The file of the backup, as the user chose it.</param>
|
||||
/// <returns>How the backup ended.</returns>
|
||||
public async Task<DataBackupResult> CreateBackupAsync(string archivePath)
|
||||
{
|
||||
if (!settingsManager.ConfigurationData.App.AllowUserToBackUpData)
|
||||
return this.Refuse(archivePath, TB("Your organization does not allow backups."));
|
||||
|
||||
//
|
||||
// A chat which is being written while it is copied may end up broken in the backup. An
|
||||
// answer or a transcript stores its chat once it is done, so the backup waits for them:
|
||||
//
|
||||
if (aiJobService.HasActiveJobs || mediaTranscriptionService.GetSnapshots().Any(snapshot => snapshot.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING))
|
||||
return this.Refuse(archivePath, TB("AI Studio is still writing an answer or a transcript. Please start the backup once it is done."));
|
||||
|
||||
CancellationTokenSource cancellation;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.running is not null)
|
||||
return new(false, false, archivePath, 0, 0, false, TB("A backup is running already."));
|
||||
|
||||
cancellation = this.running = new();
|
||||
}
|
||||
|
||||
this.ReportProgress(new(DataBackupPhase.PREPARING, 0, 0));
|
||||
var indexCopiesDirectory = string.Empty;
|
||||
var token = cancellation.Token;
|
||||
try
|
||||
{
|
||||
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
||||
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
||||
var settings = DataBackupSettings.CopyWithoutPluginObjects(settingsManager.ConfigurationData);
|
||||
var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS);
|
||||
var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory);
|
||||
|
||||
indexCopiesDirectory = await this.CopyIndexesAsync(token);
|
||||
|
||||
var sources = new DataBackupSources(dataDirectory, configDirectory, settingsJson, indexCopiesDirectory, organizationPluginFolders);
|
||||
var manifest = new DataBackupManifest(
|
||||
DataBackupFormat.FORMAT_VERSION,
|
||||
Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? "unknown",
|
||||
settings.Version.ToString(),
|
||||
DateTimeOffset.UtcNow,
|
||||
dataDirectory,
|
||||
configDirectory,
|
||||
!string.IsNullOrWhiteSpace(indexCopiesDirectory),
|
||||
[]);
|
||||
|
||||
var progress = new Progress<DataBackupProgress>(this.ReportProgress);
|
||||
await using (var archiveStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
manifest = await Task.Run(() => DataBackupWriter.WriteAsync(archiveStream, sources, manifest, progress, token), token);
|
||||
|
||||
logger.LogInformation("Backed up {FileCount} files with {Megabytes:F1} MB into '{ArchivePath}'. The indexes of the data sources are included: {IncludesIndexes}.", manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size) / (1024d * 1024d), archivePath, manifest.IncludesDataSourceIndexes);
|
||||
return this.End(new(true, false, archivePath, manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size), manifest.IncludesDataSourceIndexes, string.Empty));
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
logger.LogInformation("The user cancelled the backup into '{ArchivePath}'. The incomplete backup was removed.", archivePath);
|
||||
return this.End(new(false, true, archivePath, 0, 0, false, string.Empty));
|
||||
}
|
||||
catch (IOException exception) when (IsFileTooLarge(exception))
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
logger.LogError(exception, "The backup into '{ArchivePath}' failed, since the file system takes no file this large. The incomplete backup was removed.", archivePath);
|
||||
return this.End(new(false, false, archivePath, 0, 0, false, TB("The backup is too large for the chosen drive, which takes no files beyond 4 GB. USB sticks formatted with FAT32 are such drives. Please choose another drive.")));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.TryDeleteArchive(archivePath);
|
||||
logger.LogError(exception, "The backup into '{ArchivePath}' failed. The incomplete backup was removed.", archivePath);
|
||||
return this.End(new(false, false, archivePath, 0, 0, false, string.Format(TB("The backup failed: {0}"), exception.Message)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.TryDeleteIndexCopies(indexCopiesDirectory);
|
||||
lock (this.stateLock)
|
||||
this.running = null;
|
||||
|
||||
cancellation.Dispose();
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether writing failed since the file system takes no file this large, as FAT32 does beyond 4 GiB.
|
||||
/// </summary>
|
||||
private static bool IsFileTooLarge(IOException exception) => exception.HResult is WINDOWS_FILE_TOO_LARGE or WINDOWS_FILE_SYSTEM_LIMITATION or UNIX_FILE_TOO_LARGE;
|
||||
|
||||
/// <summary>
|
||||
/// Stops the running backup. It removes what it wrote so far.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
lock (this.stateLock)
|
||||
this.running?.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies both indexes of the data sources, while the indexing pauses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both copies are made during one pause: the vector stores have to fit to the index, or a
|
||||
/// restore would leave embeddings which belong to nothing, or files which count as embedded
|
||||
/// without their embeddings. Writing the backup afterward takes far longer, so the indexing
|
||||
/// goes on meanwhile.<br/><br/>
|
||||
///
|
||||
/// Without either database, the backup leaves both indexes out, and its manifest says so.
|
||||
/// </remarks>
|
||||
/// <returns>The directory of the copies, in the layout of the data folder, or an empty string when the backup leaves the indexes out.</returns>
|
||||
private async Task<string> CopyIndexesAsync(CancellationToken token)
|
||||
{
|
||||
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
|
||||
var vectorStore = await databaseClientProvider.GetVectorStoreAsync(token);
|
||||
if (indexStore is NoIndexStoreClient || vectorStore is NoVectorStoreClient)
|
||||
{
|
||||
logger.LogWarning("The backup leaves the indexes of the data sources out, because the index store or the vector store is not available.");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
await using var pause = await embeddingService.PauseIndexingAsync(token);
|
||||
var preparation = await vectorStore.PrepareBackupAsync(token);
|
||||
try
|
||||
{
|
||||
var indexEntry = DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.DATA_SOURCE_INDEX);
|
||||
await indexStore.BackupToDirectoryAsync(Path.Join(preparation.StagingPath, indexEntry.RelativePath.Replace(DataBackupContents.SEPARATOR, Path.DirectorySeparatorChar)), token);
|
||||
|
||||
for (var index = 0; index < preparation.Stores.Count; index++)
|
||||
{
|
||||
this.ReportProgress(new(DataBackupPhase.COPYING_INDEXES, index, preparation.Stores.Count));
|
||||
await vectorStore.CopyStoreForBackupAsync(preparation.BackupId, preparation.Stores[index].StoreName, token);
|
||||
}
|
||||
|
||||
return preparation.StagingPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
this.TryDeleteIndexCopies(preparation.StagingPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private DataBackupResult Refuse(string archivePath, string issue)
|
||||
{
|
||||
logger.LogWarning("Refused to start a backup into '{ArchivePath}': {Issue}", archivePath, issue);
|
||||
return this.End(new(false, false, archivePath, 0, 0, false, issue));
|
||||
}
|
||||
|
||||
private DataBackupResult End(DataBackupResult result)
|
||||
{
|
||||
this.LastResult = result;
|
||||
this.StateChanged?.Invoke();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReportProgress(DataBackupProgress progress)
|
||||
{
|
||||
this.Progress = progress;
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void TryDeleteArchive(string archivePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(archivePath))
|
||||
File.Delete(archivePath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not remove the incomplete backup '{ArchivePath}'.", archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the copies of the indexes. Should that fail, the next start removes them.
|
||||
/// </summary>
|
||||
private void TryDeleteIndexCopies(string indexCopiesDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(indexCopiesDirectory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(indexCopiesDirectory))
|
||||
Directory.Delete(indexCopiesDirectory, true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not remove the copies of the indexes at '{IndexCopiesDirectory}'.", indexCopiesDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot startup service which picks up what a restore or a backup left behind: it learns how the
|
||||
/// runtime applied a restore, removes what a backup or a preparation left halfway, and removes the data
|
||||
/// a restore replaced once the restored settings loaded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The report of the runtime waits in DataRestoreService until the main layout shows it, once the
|
||||
/// plugins started: only then do the providers of the organization exist, and the open points name
|
||||
/// none of them.
|
||||
/// </remarks>
|
||||
public sealed class DataRestoreFollowUpService(ILogger<DataRestoreFollowUpService> logger, SettingsManager settingsManager, DataRestoreService dataRestoreService) : BackgroundService
|
||||
{
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
/// <summary>Waits for the folders and the settings, picks up what the last start left, and then exits.</summary>
|
||||
/// <param name="stoppingToken">Host shutdown token.</param>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while ((string.IsNullOrWhiteSpace(SettingsManager.DataDirectory) || string.IsNullOrWhiteSpace(SettingsManager.ConfigDirectory)) && !stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
|
||||
await dataRestoreService.LoadStartupStateAsync(stoppingToken);
|
||||
|
||||
while (!settingsManager.HasCompletedInitialSettingsLoad && !stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
|
||||
//
|
||||
// Settings which could not be loaded leave the replaced data in place: they hold the settings
|
||||
// which worked before the restore.
|
||||
//
|
||||
if (settingsManager.SettingsWriteBlocked)
|
||||
{
|
||||
logger.LogWarning("The settings could not be loaded safely. The data which a restore replaced stay where they are.");
|
||||
return;
|
||||
}
|
||||
|
||||
dataRestoreService.RemoveReplacedData();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.Metadata;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the restore of a backup, which the next start of AI Studio applies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A restore replaces all data, so it runs only while nothing else changes it: no answer, no
|
||||
/// transcript, and no backup may be running, and the indexing pauses from the start of the preparation
|
||||
/// until the restart. Whatever changes after the preparation is lost with the restart, which is why
|
||||
/// the app asks for the restart right away, and offers to discard the restore instead.<br/><br/>
|
||||
///
|
||||
/// The service enforces what the organization allows on its own, since a component may show an
|
||||
/// outdated state.<br/><br/>
|
||||
///
|
||||
/// At the start which applied a restore, the service holds the report of the runtime until the user
|
||||
/// saw it, cf. DataRestoreFollowUpService.
|
||||
/// </remarks>
|
||||
public sealed class DataRestoreService(ILogger<DataRestoreService> logger, SettingsManager settingsManager, RustService rustService, ToolRegistry toolRegistry, ToolSettingsService toolSettingsService, DataSourceEmbeddingService embeddingService, DataBackupService dataBackupService, AIJobService aiJobService, MediaTranscriptionService mediaTranscriptionService)
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataRestoreService).Namespace, nameof(DataRestoreService));
|
||||
|
||||
private readonly Lock stateLock = new();
|
||||
|
||||
private readonly TaskCompletionSource startupStateLoaded = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private CancellationTokenSource? preparing;
|
||||
|
||||
/// <summary>
|
||||
/// Holds the indexing back while a restore is prepared or waits for the restart.
|
||||
/// </summary>
|
||||
private IAsyncDisposable? indexingPause;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the state changes: a preparation starts, makes progress, or ends, or a prepared restore is discarded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It fires on whatever thread the preparation runs. A component hands it over to its own context.
|
||||
/// </remarks>
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a restore is being prepared.
|
||||
/// </summary>
|
||||
public bool IsPreparing
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return this.preparing is not null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a restore is prepared and waits for the restart.
|
||||
/// </summary>
|
||||
public bool IsRestorePending { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How far the running preparation got.
|
||||
/// </summary>
|
||||
public DataRestoreProgress Progress { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the last preparation ended, or null when none ended yet.
|
||||
/// </summary>
|
||||
public DataRestoreResult? LastResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the runtime applied a restore at this start, until the user saw it; otherwise null.
|
||||
/// </summary>
|
||||
public DataRestoreReport? Report { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the runtime has not finished a restore and continues at the next start, cf.
|
||||
/// DataRestoreFollowUp.IsRestoreUnfinished. Until then, AI Studio neither prepares nor discards a
|
||||
/// restore.
|
||||
/// </summary>
|
||||
public bool IsRestoreUnfinished { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Completes once the service knows what the last start left behind, cf. LoadStartupStateAsync.
|
||||
/// </summary>
|
||||
public Task StartupStateLoaded => this.startupStateLoaded.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Learns at the start what the runtime and the last run left behind: the report of a restore, a
|
||||
/// restore which still waits for a restart, and what a backup or a preparation left halfway.
|
||||
/// </summary>
|
||||
/// <param name="token">Cancels reading the report.</param>
|
||||
public async Task LoadStartupStateAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
||||
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
||||
try
|
||||
{
|
||||
this.Report = await DataRestoreFollowUp.ReadReportAsync(dataDirectory, token);
|
||||
if (this.Report is not null)
|
||||
logger.LogInformation("The runtime applied a restore at this start: {Outcome}. {Detail}", this.Report.Outcome, this.Report.Detail);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or IOException)
|
||||
{
|
||||
logger.LogError(exception, "Could not read how the runtime applied a restore.");
|
||||
DataRestoreFollowUp.RemoveReport(dataDirectory);
|
||||
}
|
||||
|
||||
this.IsRestoreUnfinished = DataRestoreFollowUp.IsRestoreUnfinished(dataDirectory);
|
||||
if (this.IsRestoreUnfinished)
|
||||
logger.LogError("The runtime has not finished a restore. It tries to move the replaced data back at the next start.");
|
||||
|
||||
if (!dataBackupService.IsRunning && !this.IsPreparing)
|
||||
DataRestoreFollowUp.RemoveLeftovers(dataDirectory, configDirectory);
|
||||
|
||||
//
|
||||
// A restore which still waits for its start, e.g., in the development environment, where the
|
||||
// .NET server may restart without the runtime: whatever changes until then is lost.
|
||||
//
|
||||
if (DataRestoreFollowUp.IsRestorePending(dataDirectory))
|
||||
{
|
||||
logger.LogWarning("A prepared restore still waits for the restart which applies it.");
|
||||
this.IsRestorePending = true;
|
||||
this.indexingPause ??= await embeddingService.PauseIndexingAsync(token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not learn what the last start left behind of a restore or a backup.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.startupStateLoaded.TrySetResult();
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the data which a restore replaced. Call it once the restored settings loaded.
|
||||
/// </summary>
|
||||
public void RemoveReplacedData()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
||||
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
||||
if (DataRestoreFollowUp.RemoveReplacedData(dataDirectory, configDirectory))
|
||||
logger.LogInformation("Removed the data which the last restore replaced.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not remove the data which the last restore replaced.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds what the user still has to do after the restore of this start, e.g., enter API keys again.
|
||||
/// </summary>
|
||||
/// <returns>The open points; none when no restore was applied.</returns>
|
||||
public async Task<IReadOnlyList<DataRestoreOpenPoint>> CollectOpenPointsAsync()
|
||||
{
|
||||
if (this.Report?.Outcome is not DataRestoreOutcome.APPLIED)
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return await DataRestoreOpenPoints.CollectAsync(settingsManager.ConfigurationData, new RustStoredSecrets(rustService), this.FindToolSecrets());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not find what remains to do after the restore.");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the secret settings of the tools which the user set up, i.e., saved the settings of.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A setting the organization fixed comes from its configuration plugin, and one the tool does
|
||||
/// not show for the saved settings means nothing for them. Which of the remaining ones held a
|
||||
/// value nobody knows: a backup holds no secrets, so the dialog asks to enter them again if they
|
||||
/// were used.
|
||||
/// </remarks>
|
||||
private List<DataRestoreToolSecret> FindToolSecrets()
|
||||
{
|
||||
var toolSecrets = new List<DataRestoreToolSecret>();
|
||||
foreach (var definition in toolRegistry.GetAllDefinitions())
|
||||
{
|
||||
if (toolRegistry.GetImplementation(definition.ImplementationKey) is not { } implementation)
|
||||
continue;
|
||||
|
||||
if (!settingsManager.ConfigurationData.Tools.Settings.TryGetValue(definition.Id, out var storedValues))
|
||||
continue;
|
||||
|
||||
foreach (var (fieldName, field) in definition.SettingsSchema.Properties)
|
||||
if (field.Secret && !toolSettingsService.IsFieldLocked(definition, fieldName) && implementation.IsSettingsFieldVisible(fieldName, storedValues))
|
||||
toolSecrets.Add(new(definition.Id, fieldName, implementation.GetDisplayName(), implementation.GetSettingsFieldLabel(fieldName, field)));
|
||||
}
|
||||
|
||||
return toolSecrets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the report of the runtime, once the user saw it.
|
||||
/// </summary>
|
||||
public void ForgetReport()
|
||||
{
|
||||
this.Report = null;
|
||||
try
|
||||
{
|
||||
DataRestoreFollowUp.RemoveReport(SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet."));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not remove the report of the restore.");
|
||||
}
|
||||
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the restore of a backup for the next start.
|
||||
/// </summary>
|
||||
/// <param name="archivePath">The backup.</param>
|
||||
/// <returns>Whether the restore is prepared, or why not.</returns>
|
||||
public async Task<DataRestoreResult> PrepareRestoreAsync(string archivePath)
|
||||
{
|
||||
if (!settingsManager.ConfigurationData.App.AllowUserToRestoreData)
|
||||
return this.Refuse(TB("Your organization does not allow restoring backups."));
|
||||
|
||||
if (dataBackupService.IsRunning)
|
||||
return this.Refuse(TB("A backup is running. Please restore once it is done."));
|
||||
|
||||
if (this.IsRestoreUnfinished)
|
||||
return this.Refuse(TB("AI Studio has not finished restoring an earlier backup yet. Please restart AI Studio first."));
|
||||
|
||||
if (aiJobService.HasActiveJobs || mediaTranscriptionService.GetSnapshots().Any(snapshot => snapshot.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING))
|
||||
return this.Refuse(TB("AI Studio is still writing an answer or a transcript. Please restore once it is done."));
|
||||
|
||||
CancellationTokenSource cancellation;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.preparing is not null)
|
||||
return new(false, false, TB("A restore is being prepared already."));
|
||||
|
||||
cancellation = this.preparing = new();
|
||||
}
|
||||
|
||||
this.ReportProgress(new(0, 0));
|
||||
var token = cancellation.Token;
|
||||
try
|
||||
{
|
||||
var target = this.CreateTarget();
|
||||
this.indexingPause ??= await embeddingService.PauseIndexingAsync(token);
|
||||
|
||||
var progress = new Progress<DataRestoreProgress>(this.ReportProgress);
|
||||
var result = await Task.Run(() => DataRestorePreparer.PrepareAsync(archivePath, target, progress, token), token);
|
||||
this.IsRestorePending = result.Success;
|
||||
if (result.Success)
|
||||
logger.LogInformation("Prepared the restore of the backup '{ArchivePath}'. It is applied at the next start.", archivePath);
|
||||
else
|
||||
logger.LogWarning("Could not prepare the restore of the backup '{ArchivePath}': {Issue}", archivePath, result.Issue);
|
||||
|
||||
return this.End(result);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("The user cancelled preparing the restore of the backup '{ArchivePath}'.", archivePath);
|
||||
return this.End(new(false, true, string.Empty));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// The preparation removes what it unpacked when it fails:
|
||||
logger.LogError(exception, "Preparing the restore of the backup '{ArchivePath}' failed.", archivePath);
|
||||
return this.End(new(false, false, string.Format(TB("The restore could not be prepared: {0}"), exception.Message)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
this.preparing = null;
|
||||
|
||||
cancellation.Dispose();
|
||||
if (!this.IsRestorePending)
|
||||
await this.EndIndexingPauseAsync();
|
||||
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the running preparation. It removes what it unpacked so far.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
lock (this.stateLock)
|
||||
this.preparing?.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discards a prepared restore, so that the next start leaves all data as it is.
|
||||
/// </summary>
|
||||
public async Task DiscardPendingRestoreAsync()
|
||||
{
|
||||
if (this.IsPreparing || this.IsRestoreUnfinished)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var target = this.CreateTarget();
|
||||
try
|
||||
{
|
||||
DataRestorePreparer.Discard(target);
|
||||
logger.LogInformation("Discarded the prepared restore.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// What remains on the disk decides, should discarding have failed halfway:
|
||||
this.IsRestorePending = DataRestorePreparer.IsPrepared(target);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not discard the prepared restore.");
|
||||
}
|
||||
|
||||
// A restore which the next start still applies keeps the indexing paused:
|
||||
if (!this.IsRestorePending)
|
||||
await this.EndIndexingPauseAsync();
|
||||
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private DataRestoreTarget CreateTarget()
|
||||
{
|
||||
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
||||
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
||||
var appVersion = Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? string.Empty;
|
||||
var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory);
|
||||
|
||||
// Loading the settings brings them to the version this AI Studio writes:
|
||||
return new(dataDirectory, configDirectory, appVersion, settingsManager.ConfigurationData.Version, organizationPluginFolders);
|
||||
}
|
||||
|
||||
private async Task EndIndexingPauseAsync()
|
||||
{
|
||||
var pause = this.indexingPause;
|
||||
this.indexingPause = null;
|
||||
if (pause is not null)
|
||||
await pause.DisposeAsync();
|
||||
}
|
||||
|
||||
private DataRestoreResult Refuse(string issue)
|
||||
{
|
||||
logger.LogWarning("Refused to prepare a restore: {Issue}", issue);
|
||||
return this.End(new(false, false, issue));
|
||||
}
|
||||
|
||||
private DataRestoreResult End(DataRestoreResult result)
|
||||
{
|
||||
this.LastResult = result;
|
||||
this.StateChanged?.Invoke();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReportProgress(DataRestoreProgress progress)
|
||||
{
|
||||
this.Progress = progress;
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed partial class DataSourceEmbeddingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the indexing is paused right now, e.g., while a backup copies the indexes, or while a
|
||||
/// restore waits for the restart.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deleting or repairing a data source waits for the end of a pause, so the user interface asks
|
||||
/// first and tells the user instead of waiting.
|
||||
/// </remarks>
|
||||
public bool IsIndexingPaused => this.pauses.IsPaused;
|
||||
|
||||
/// <summary>
|
||||
/// Pauses the indexing of every data source, e.g., while a backup copies the indexes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every run stops, and the pause waits until they have, since a run may be about to write to
|
||||
/// both stores. A document a run embedded completely stays stored, and its fingerprint keeps the
|
||||
/// next run from embedding it once more. Every run asked for during the pause waits for its end,
|
||||
/// the next sync of a mailbox among them. Ending the pause queues every data source whose run it
|
||||
/// stopped, for the same reason that run was asked for.<br/><br/>
|
||||
///
|
||||
/// Pauses may overlap. The indexing goes on once the last one ended.
|
||||
/// </remarks>
|
||||
/// <param name="token">Stops waiting for the runs to stop, which ends the pause as well.</param>
|
||||
/// <returns>The pause. Disposing it ends the pause.</returns>
|
||||
public async Task<IAsyncDisposable> PauseIndexingAsync(CancellationToken token)
|
||||
{
|
||||
//
|
||||
// The pause begins before any run is stopped. A run which starts meanwhile is either among
|
||||
// the active runs below, or it finds the indexing paused and waits:
|
||||
//
|
||||
this.pauses.Begin();
|
||||
var pause = new IndexingPause(this);
|
||||
|
||||
var stoppedRuns = new List<Task>();
|
||||
foreach (var (dataSourceId, activeRun) in this.activeRuns)
|
||||
{
|
||||
if (!TryCancelRun(activeRun))
|
||||
continue;
|
||||
|
||||
this.pauses.RecordStoppedRun(dataSourceId, activeRun.RefreshMode);
|
||||
stoppedRuns.Add(activeRun.Completion.Task);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(stoppedRuns).WaitAsync(token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await pause.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
logger.LogInformation("Paused the indexing of all data sources. Stopped {StoppedRuns} active runs.", stoppedRuns.Count);
|
||||
return pause;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends a pause, and once the last one ended, queues every data source whose run a pause stopped.
|
||||
/// </summary>
|
||||
private async Task EndPauseAsync()
|
||||
{
|
||||
var stoppedRuns = this.pauses.End();
|
||||
foreach (var stoppedRun in stoppedRuns)
|
||||
{
|
||||
// Deleted during the pause, so there is nothing to carry on with:
|
||||
if (!this.TryGetConfiguredIndexedSource(stoppedRun.DataSourceId, out var dataSource))
|
||||
continue;
|
||||
|
||||
await this.QueueDataSourceAsync(dataSource, true, stoppedRun.RefreshMode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One pause, which ends once it is disposed.
|
||||
/// </summary>
|
||||
private sealed class IndexingPause(DataSourceEmbeddingService service) : IAsyncDisposable
|
||||
{
|
||||
private int ended;
|
||||
|
||||
/// <summary>
|
||||
/// Ends the pause. Ending it again does nothing.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref this.ended, 1) == 1)
|
||||
return;
|
||||
|
||||
await service.EndPauseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user