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:
j-erlerandThorsten Sommer authored and GitHub committed 2026-10-15 16:42:02 +02:00
1 parent ab45580473
commit 936043319c
144 files changed
+10417 -424

No files matched your search

@@ -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>