mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-16 22:17:59 +00:00
Added importing of configurations exported from AI Studio (#1007)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
f8c0232cfd
commit
22065783c2
49 files changed
+3257
-263
No files matched your search
@@ -41,3 +41,27 @@ When adding configuration plugin capabilities:
|
||||
- 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`.
|
||||
|
||||
## Importing exported configuration objects
|
||||
|
||||
Users can paste a configuration object which somebody exported, such as a chat template, and keep it as one
|
||||
of their own. Three parts carry this:
|
||||
|
||||
- `ConfigurationSnippetParser` reads the pasted text as data and never creates a Lua runtime: the text comes
|
||||
from somebody else, so it earns none of the trust a deployed plugin has. It accepts only what an export
|
||||
writes, and it reads that the way Lua does. `ConfigurationSnippetParserTests` compares both for every
|
||||
export; extend the parser only together with that test, and never toward running code.
|
||||
- `ConfigurationSnippetImport` reads the table through `TryParseSnippet` of the descriptor, i.e., with the
|
||||
parser a configuration plugin goes through, as a dry run. It then makes the object local: an ID of its own,
|
||||
no plugin. Do not add a second description of the fields for the import.
|
||||
- `DataApp.CanImportConfigurationSnippet` decides who may import which type.
|
||||
|
||||
A snippet may name things outside of itself: a file, a server, another configuration object. None of them may
|
||||
exist on this computer, and none of them may be touched before the user agreed to the import. The importer
|
||||
therefore looks up no path on a network share and contacts no server; the forms do that once the user asks.
|
||||
|
||||
A new type of configuration object needs a decision on its import: a switch in `DataApp`, its case in
|
||||
`CanImportConfigurationSnippet`, and its documentation in `plugin.lua`, plus its export in
|
||||
`ConfigurationSnippetParserTests` and its case in `ConfigurationSnippetImport.TryImport`. The coverage tests in
|
||||
`DataAppImportPermissionTests`, `ConfigurationSnippetParserTests`, and `ConfigurationSnippetImportTests` fail
|
||||
for a type without them.
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
@@ -21,10 +22,8 @@ public sealed class ConfigurationBaseObjectDescriptor<TClass>(PluginConfiguratio
|
||||
/// <inheritdoc />
|
||||
public override PluginConfigurationObjectType Type => type;
|
||||
|
||||
/// <summary>
|
||||
/// The Lua table in which a configuration plugin defines these objects.
|
||||
/// </summary>
|
||||
public string LuaTableName => luaTableName;
|
||||
/// <inheritdoc />
|
||||
public override string LuaTableName => luaTableName;
|
||||
|
||||
/// <summary>
|
||||
/// Where the settings keep these objects.
|
||||
@@ -44,6 +43,14 @@ public sealed class ConfigurationBaseObjectDescriptor<TClass>(PluginConfiguratio
|
||||
/// <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 bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject)
|
||||
{
|
||||
var isValid = parser(1, table, Guid.Empty, string.Empty, dryRun: true, out var parsedObject);
|
||||
configObject = isValid ? parsedObject : null;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a configuration snippet which somebody exported into a configuration object of the user's own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The snippet is read by the same parsers which read configuration plugins, so an import accepts
|
||||
/// what a configuration plugin accepts, and there is no second description of the fields to keep in
|
||||
/// step with the first. The parsers run as a dry run, which stores nothing. Afterward, the object is
|
||||
/// made local: it gets an ID of its own and belongs to no configuration plugin. What the snippet
|
||||
/// refers to beyond itself, a file or another configuration object, may not exist on this computer;
|
||||
/// the user hears about it instead of losing the whole snippet.
|
||||
/// </remarks>
|
||||
public static class ConfigurationSnippetImport
|
||||
{
|
||||
private const string FILE_ATTACHMENTS_FIELD = "FileAttachments";
|
||||
|
||||
private const string API_KEY_FIELD = "APIKey";
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ConfigurationSnippetImport).Namespace, nameof(ConfigurationSnippetImport));
|
||||
|
||||
/// <summary>
|
||||
/// Reads a pasted configuration snippet and makes its configuration object one of the user's own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Secrets are decrypted with the encryption secret of the organization, if this computer has
|
||||
/// one. What the object refers to in the settings is checked by ResolveReferencesAsync.
|
||||
/// </remarks>
|
||||
/// <param name="snippet">The pasted snippet.</param>
|
||||
/// <param name="type">The type of configuration object the snippet must describe.</param>
|
||||
/// <param name="result">The imported configuration object, when the snippet could be imported.</param>
|
||||
/// <param name="issue">Why the snippet could not be imported, otherwise empty.</param>
|
||||
/// <returns>True when the snippet could be imported.</returns>
|
||||
public static bool TryImport(string snippet, PluginConfigurationObjectType type, [NotNullWhen(true)] out ConfigurationSnippetImportResult? result, out string issue)
|
||||
{
|
||||
result = null;
|
||||
if (!ConfigurationSnippetParser.TryParse(snippet, type, out var table, out issue))
|
||||
return false;
|
||||
|
||||
//
|
||||
// The parser of chat templates looks up every attachment, on a network share as well. Since
|
||||
// a snippet comes from somebody else, that lookup would reach out to whatever share they
|
||||
// named as soon as the snippet was pasted. Hence, the attachments are taken out beforehand
|
||||
// and checked here, without touching a share:
|
||||
//
|
||||
var attachmentPaths = type is PluginConfigurationObjectType.CHAT_TEMPLATE ? TakeAttachmentPaths(table) : [];
|
||||
if (!PluginConfigurationObjectDescriptors.Get(type).TryParseSnippet(table, out var configObject))
|
||||
{
|
||||
issue = DescribeInvalidSnippet(type, table);
|
||||
return false;
|
||||
}
|
||||
|
||||
var hints = new List<string>();
|
||||
switch (configObject)
|
||||
{
|
||||
case DataSourceERI_V1 dataSource:
|
||||
if (dataSource.AuthMethod is AuthMethod.KERBEROS)
|
||||
{
|
||||
issue = TB("Data sources which sign in with Kerberos cannot be imported yet. Please add them yourself.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// The parser already decrypted the secret once, so it succeeds here as well:
|
||||
TryReadSecret(table, DataSourceERI_V1.GetSecretFieldName(dataSource.AuthMethod), out var dataSourceSecret);
|
||||
result = new(MakeLocal(dataSource), dataSourceSecret, hints, []);
|
||||
return true;
|
||||
|
||||
//
|
||||
// A user-provided API key is what an organization grants for a provider it manages. A
|
||||
// provider of the user's own has nothing else, so the flag carries no meaning here:
|
||||
//
|
||||
case Settings.Provider provider:
|
||||
result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
|
||||
return true;
|
||||
|
||||
case EmbeddingProvider provider:
|
||||
result = new(MakeLocal(provider) with { TokenizerPath = KeepLocalTokenizer(provider.TokenizerPath, hints), AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
|
||||
return true;
|
||||
|
||||
case TranscriptionProvider provider:
|
||||
result = new(MakeLocal(provider) with { AllowUserProvidedAPIKey = false }, ReadAPIKey(table, hints), hints, []);
|
||||
return true;
|
||||
|
||||
//
|
||||
// A snippet may name any file of this computer, a private key just as well as a report.
|
||||
// Hence, the user gets to see which files the template would attach to every chat:
|
||||
//
|
||||
case ChatTemplate template:
|
||||
var localPaths = attachmentPaths.Where(IsLocalFile).ToList();
|
||||
if (localPaths.Count > 0)
|
||||
hints.Add(string.Format(TB("The chat template attaches these files from this computer to every chat started with it: {0}. Please check that you want to share them."), string.Join(", ", localPaths)));
|
||||
|
||||
result = new(MakeLocal(template) with { FileAttachments = localPaths.Select(FileAttachment.FromPath).ToList() }, string.Empty, hints, attachmentPaths.Except(localPaths).ToList());
|
||||
return true;
|
||||
|
||||
case DataDocumentAnalysisPolicy policy:
|
||||
result = new(MakeLocal(policy) with { IsProtected = false }, string.Empty, hints, []);
|
||||
return true;
|
||||
|
||||
case Profile profile:
|
||||
result = new(MakeLocal(profile), string.Empty, hints, []);
|
||||
return true;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Configuration objects of the type {type} cannot be imported yet.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks what an imported configuration object refers to in the settings: the providers,
|
||||
/// profiles, data sources, and tools it preselects or permits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The selected tools are normalized first, as every selection of tools is. A reference which
|
||||
/// cannot be found stays in the object, as it would in a configuration plugin: the snippet may
|
||||
/// stem from a computer which has more, and the user may add what is missing later.
|
||||
/// </remarks>
|
||||
/// <param name="result">What TryImport read from the snippet.</param>
|
||||
/// <param name="settingsManager">The settings in which the references must exist.</param>
|
||||
/// <param name="toolRegistry">The tools which are available.</param>
|
||||
/// <returns>The result with the tools normalized and a hint for each kind of missing reference.</returns>
|
||||
public static async Task<ConfigurationSnippetImportResult> ResolveReferencesAsync(ConfigurationSnippetImportResult result, SettingsManager settingsManager, ToolRegistry toolRegistry)
|
||||
{
|
||||
var hints = new List<string>(result.Hints);
|
||||
switch (result.ConfigurationObject)
|
||||
{
|
||||
case ChatTemplate template:
|
||||
var templateToolIds = template.ToolIds is null ? null : toolRegistry.NormalizeSelection(template.ToolIds);
|
||||
var missingDataSourceIds = (template.DataSourceOptions?.PreselectedDataSourceIds ?? [])
|
||||
.Where(id => settingsManager.ConfigurationData.DataSources.All(dataSource => dataSource.Id != id))
|
||||
.ToList();
|
||||
|
||||
if (missingDataSourceIds.Count > 0)
|
||||
hints.Add(string.Format(TB("The chat template preselects data sources which do not exist on this computer: {0}."), string.Join(", ", missingDataSourceIds)));
|
||||
|
||||
var missingChatToolIds = await FindMissingToolIdsAsync(templateToolIds ?? [], toolRegistry, Components.CHAT);
|
||||
if (missingChatToolIds.Count > 0)
|
||||
hints.Add(string.Format(TB("The chat template preselects tools which are not available on this computer: {0}."), string.Join(", ", missingChatToolIds)));
|
||||
|
||||
return result with { ConfigurationObject = template with { ToolIds = templateToolIds }, Hints = hints };
|
||||
|
||||
case DataDocumentAnalysisPolicy policy:
|
||||
var allowedToolIds = toolRegistry.NormalizeSelection(policy.AllowedToolIds);
|
||||
if (!string.IsNullOrWhiteSpace(policy.PreselectedProvider) && settingsManager.GetProviderById(policy.PreselectedProvider) == Settings.Provider.NONE)
|
||||
hints.Add(string.Format(TB("The policy preselects a provider which does not exist on this computer: {0}."), policy.PreselectedProvider));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policy.PreselectedProfile) && policy.PreselectedProfile != Profile.NO_PROFILE.Id && settingsManager.ConfigurationData.Profiles.All(profile => profile.Id != policy.PreselectedProfile))
|
||||
hints.Add(string.Format(TB("The policy preselects a profile which does not exist on this computer: {0}."), policy.PreselectedProfile));
|
||||
|
||||
var missingPolicyToolIds = await FindMissingToolIdsAsync(allowedToolIds, toolRegistry, Components.DOCUMENT_ANALYSIS_ASSISTANT);
|
||||
if (missingPolicyToolIds.Count > 0)
|
||||
hints.Add(string.Format(TB("The policy permits tools which are not available on this computer: {0}."), string.Join(", ", missingPolicyToolIds)));
|
||||
|
||||
return result with { ConfigurationObject = policy with { AllowedToolIds = allowedToolIds }, Hints = hints };
|
||||
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<string>> FindMissingToolIdsAsync(IReadOnlyCollection<string> toolIds, ToolRegistry toolRegistry, Components component)
|
||||
{
|
||||
if (toolIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var availableToolIds = (await toolRegistry.GetCatalogAsync(component)).Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
|
||||
return toolIds.Where(id => !availableToolIds.Contains(id)).Order(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static T MakeLocal<T>(T configObject) where T : ConfigurationBaseObject => (T)((ConfigurationBaseObject)configObject with
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Num = 0,
|
||||
IsEnterpriseConfiguration = false,
|
||||
EnterpriseConfigurationPluginId = Guid.Empty,
|
||||
});
|
||||
|
||||
private static DataSourceERI_V1 MakeLocal(DataSourceERI_V1 dataSource) => dataSource with
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Num = 0,
|
||||
IsEnterpriseConfiguration = false,
|
||||
EnterpriseConfigurationPluginId = Guid.Empty,
|
||||
|
||||
// Only an organization manages usernames and passwords for its users, so the user manages them here:
|
||||
UsernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED,
|
||||
Username = dataSource.Username == DataSourceERI_V1.SHARED_USERNAME_PLACEHOLDER ? string.Empty : dataSource.Username,
|
||||
};
|
||||
|
||||
private static List<string> TakeAttachmentPaths(LuaTable table)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
if (table.TryGetValue(FILE_ATTACHMENTS_FIELD, out var attachmentsValue) && attachmentsValue.TryRead<LuaTable>(out var attachments))
|
||||
{
|
||||
for (var index = 1; index <= attachments.ArrayLength; index++)
|
||||
{
|
||||
if (attachments[index].TryRead<string>(out var path) && !string.IsNullOrWhiteSpace(path) && !paths.Contains(path))
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
table[FILE_ATTACHMENTS_FIELD] = LuaValue.Nil;
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static string KeepLocalTokenizer(string tokenizerPath, List<string> hints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenizerPath) || IsLocalFile(tokenizerPath))
|
||||
return tokenizerPath;
|
||||
|
||||
hints.Add(string.Format(TB("The tokenizer '{0}' is not on this computer, so the provider is imported without it."), tokenizerPath));
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a path names a file on this computer. A path on a network share never does: looking
|
||||
/// it up would already connect to the share.
|
||||
/// </summary>
|
||||
private static bool IsLocalFile(string path) => Path.IsPathFullyQualified(path) && !path.StartsWith(@"\\", StringComparison.Ordinal) && !path.StartsWith("//", StringComparison.Ordinal) && File.Exists(path);
|
||||
|
||||
/// <remarks>
|
||||
/// Unlike the secret of a data source, an API key is no part of what makes a provider valid, so
|
||||
/// a key which cannot be read leaves only a hint: the user enters a key of their own instead.
|
||||
/// </remarks>
|
||||
private static string ReadAPIKey(LuaTable table, List<string> hints)
|
||||
{
|
||||
var encryptedApiKey = ReadString(table, API_KEY_FIELD);
|
||||
if (string.IsNullOrWhiteSpace(encryptedApiKey))
|
||||
return string.Empty;
|
||||
|
||||
if (!EnterpriseEncryption.IsEncrypted(encryptedApiKey))
|
||||
{
|
||||
hints.Add(TB("The pasted configuration contains an API key which is not encrypted, so it was left out. Please enter your API key before you save the provider."));
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (TryReadSecret(table, API_KEY_FIELD, out var apiKey))
|
||||
return apiKey;
|
||||
|
||||
hints.Add(TB("The API key in the pasted configuration cannot be decrypted on this computer. Please enter your API key before you save the provider."));
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryReadSecret(LuaTable table, string fieldName, out string secret)
|
||||
{
|
||||
secret = string.Empty;
|
||||
var encryptedSecret = ReadString(table, fieldName);
|
||||
if (!EnterpriseEncryption.IsEncrypted(encryptedSecret))
|
||||
return false;
|
||||
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
if (encryption?.IsAvailable != true || !encryption.TryDecrypt(encryptedSecret, out var decryptedSecret))
|
||||
return false;
|
||||
|
||||
secret = decryptedSecret;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ReadString(LuaTable table, string fieldName) => !string.IsNullOrWhiteSpace(fieldName) && table.TryGetValue(fieldName, out var value) && value.TryRead<string>(out var text) ? text : string.Empty;
|
||||
|
||||
private static string DescribeInvalidSnippet(PluginConfigurationObjectType type, LuaTable table)
|
||||
{
|
||||
//
|
||||
// A data source which signs in with a secret is only valid where its secret can be
|
||||
// decrypted. That is no fault of the snippet, so the user learns the actual reason:
|
||||
//
|
||||
if (type is PluginConfigurationObjectType.DATA_SOURCE && Enum.TryParse<AuthMethod>(ReadString(table, "AuthMethod"), true, out var authMethod))
|
||||
{
|
||||
var secretFieldName = DataSourceERI_V1.GetSecretFieldName(authMethod);
|
||||
if (EnterpriseEncryption.IsEncrypted(ReadString(table, secretFieldName)) && !TryReadSecret(table, secretFieldName, out _))
|
||||
return TB("The token or password of the data source cannot be decrypted on this computer. Such data sources can only be imported where the encryption secret of your organization is configured.");
|
||||
}
|
||||
|
||||
return string.Format(TB("The pasted configuration is not a valid configuration of the type '{0}'."), type.GetName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// A configuration object read from a pasted configuration snippet, ready to become one of the user's own.
|
||||
/// </summary>
|
||||
/// <param name="ConfigurationObject">The configuration object, with an ID of its own and belonging to no configuration plugin.</param>
|
||||
/// <param name="Secret">The decrypted API key, token, or password which the snippet brought along, or an empty string.</param>
|
||||
/// <param name="Hints">What the user should know before adding the object, each as a sentence of its own.</param>
|
||||
/// <param name="AttachmentsToRelink">The file attachments of a chat template which are not on this computer, as the snippet names them.</param>
|
||||
public sealed record ConfigurationSnippetImportResult(IConfigurationObject ConfigurationObject, string Secret, IReadOnlyList<string> Hints, IReadOnlyList<string> AttachmentsToRelink);
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>Reads one exported configuration assignment as data. No Lua state is created.</summary>
|
||||
/// <remarks>
|
||||
/// A snippet is text somebody else wrote, pasted by a user who cannot tell what it does. Running it,
|
||||
/// even in the sandbox of the plugins, would let it do whatever Lua allows: loop forever, fill the
|
||||
/// memory, or compute its values from anything within reach. Configuration plugins may run code
|
||||
/// because an organization deploys them; a snippet earns no such trust. This parser therefore
|
||||
/// accepts only what an export writes: one assignment of a table made of strings, numbers,
|
||||
/// booleans, and tables, with comments in between. Whatever it accepts, it reads as Lua would, so a
|
||||
/// snippet means the same here as in a configuration plugin. Anything else is refused, never
|
||||
/// guessed: a construct which Lua would read differently must not slip through as data.
|
||||
/// </remarks>
|
||||
public sealed class ConfigurationSnippetParser
|
||||
{
|
||||
private readonly string source;
|
||||
private int position;
|
||||
|
||||
private ConfigurationSnippetParser(string source) => this.source = source;
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ConfigurationSnippetParser).Namespace, nameof(ConfigurationSnippetParser));
|
||||
|
||||
/// <summary>
|
||||
/// Reads a snippet of the form <c>CONFIG["SECTION"][#CONFIG["SECTION"]+1] = { ... }</c>.
|
||||
/// </summary>
|
||||
/// <param name="source">The pasted snippet.</param>
|
||||
/// <param name="type">The type of configuration object the snippet must describe.</param>
|
||||
/// <param name="table">The table the snippet assigns, when it could be read.</param>
|
||||
/// <param name="issue">Why the snippet could not be read, otherwise empty.</param>
|
||||
/// <returns>True when the snippet could be read.</returns>
|
||||
public static bool TryParse(string source, PluginConfigurationObjectType type, out LuaTable table, out string issue)
|
||||
{
|
||||
table = new LuaTable();
|
||||
issue = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(source) || source.Length > 1_000_000)
|
||||
{
|
||||
issue = TB("Please paste one exported configuration of at most 1 MB.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var expectedSection = PluginConfigurationObjectDescriptors.Get(type).LuaTableName;
|
||||
var parser = new ConfigurationSnippetParser(source);
|
||||
parser.ExpectWord("CONFIG");
|
||||
var section = parser.ReadBracketedString();
|
||||
if (section != expectedSection)
|
||||
throw new FormatException(PluginConfigurationObjectDescriptors.TryGet(section, out var otherDescriptor)
|
||||
? string.Format(TB("The pasted configuration describes the type '{0}', but only the type '{1}' can be imported here."), otherDescriptor.Type.GetName(), type.GetName())
|
||||
: string.Format(TB("The pasted configuration does not describe the type '{0}', which can be imported here."), type.GetName()));
|
||||
|
||||
parser.Expect('[');
|
||||
parser.Expect('#');
|
||||
parser.ExpectWord("CONFIG");
|
||||
if (parser.ReadBracketedString() != expectedSection)
|
||||
throw new FormatException(TB("The pasted configuration names two different types of configuration."));
|
||||
parser.Expect('+');
|
||||
parser.Expect('1');
|
||||
parser.Expect(']');
|
||||
parser.Expect('=');
|
||||
table = parser.ReadTable(0);
|
||||
parser.SkipTrivia();
|
||||
if (parser.position != source.Length)
|
||||
throw new FormatException(TB("The pasted configuration must consist of exactly one exported item, without any further code."));
|
||||
return true;
|
||||
}
|
||||
catch (FormatException exception)
|
||||
{
|
||||
issue = exception.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private LuaTable ReadTable(int depth)
|
||||
{
|
||||
if (depth > 32)
|
||||
throw new FormatException(TB("The pasted configuration contains too many nested tables."));
|
||||
this.Expect('{');
|
||||
var table = new LuaTable();
|
||||
var arrayIndex = 1;
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
while (true)
|
||||
{
|
||||
this.SkipTrivia();
|
||||
if (this.Take('}'))
|
||||
return table;
|
||||
|
||||
// "[[" or "[==[" opens a long string, which is an array value rather than a bracketed key:
|
||||
if (this.Peek() == '[' && !this.IsLongStringStart())
|
||||
{
|
||||
this.position++;
|
||||
var key = this.ReadString();
|
||||
this.Expect(']');
|
||||
this.Expect('=');
|
||||
if (!names.Add(key))
|
||||
throw new FormatException(string.Format(TB("The field '{0}' occurs more than once."), key));
|
||||
table[key] = this.ReadValue(depth + 1);
|
||||
}
|
||||
else
|
||||
table[arrayIndex++] = this.ReadValue(depth + 1);
|
||||
|
||||
this.SkipTrivia();
|
||||
if (this.Take('}'))
|
||||
return table;
|
||||
if (!this.Take(',') && !this.Take(';'))
|
||||
throw new FormatException(string.Format(TB("Expected a comma or closing brace at character {0}."), this.position + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private LuaValue ReadValue(int depth)
|
||||
{
|
||||
this.SkipTrivia();
|
||||
if (this.Peek() == '{')
|
||||
return this.ReadTable(depth);
|
||||
if (this.Peek() is '"' or '\'' || this.Peek() == '[')
|
||||
return this.ReadString();
|
||||
if (this.TakeWord("true"))
|
||||
return true;
|
||||
if (this.TakeWord("false"))
|
||||
return false;
|
||||
|
||||
// In Lua, nil leaves a field out, and in a list it ends what the parsers read of it:
|
||||
if (this.TakeWord("nil"))
|
||||
throw new FormatException(string.Format(TB("The value nil at character {0} is not supported. Leave the field out instead."), this.position - 2));
|
||||
|
||||
var start = this.position;
|
||||
if (this.Peek() == '-')
|
||||
this.position++;
|
||||
while (char.IsAsciiDigit(this.Peek()))
|
||||
this.position++;
|
||||
if (this.Peek() == '.')
|
||||
{
|
||||
this.position++;
|
||||
while (char.IsAsciiDigit(this.Peek()))
|
||||
this.position++;
|
||||
}
|
||||
if (this.position > start && double.TryParse(this.source[start..this.position], NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number))
|
||||
return number;
|
||||
throw new FormatException(string.Format(TB("At character {0}, the pasted configuration contains code. Only plain values such as texts, numbers, true, and false are allowed."), start + 1));
|
||||
}
|
||||
|
||||
private bool IsLongStringStart() => this.IsLongBracketAt(this.position);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a long bracket opens at this index: '[', any number of '=', and '[' again.
|
||||
/// </summary>
|
||||
private bool IsLongBracketAt(int index)
|
||||
{
|
||||
if (index >= this.source.Length || this.source[index] != '[')
|
||||
return false;
|
||||
|
||||
index++;
|
||||
while (index < this.source.Length && this.source[index] == '=')
|
||||
index++;
|
||||
|
||||
return index < this.source.Length && this.source[index] == '[';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips one line break, which Lua counts as one whether it is \n, \r, \r\n, or \n\r.
|
||||
/// </summary>
|
||||
private void SkipLineBreak()
|
||||
{
|
||||
var first = this.Peek();
|
||||
if (first is not ('\r' or '\n'))
|
||||
return;
|
||||
|
||||
this.position++;
|
||||
var second = this.Peek();
|
||||
if (second is '\r' or '\n' && second != first)
|
||||
this.position++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns every line break of a long string into \n, as Lua does.
|
||||
/// </summary>
|
||||
private static string NormalizeLineBreaks(string text)
|
||||
{
|
||||
if (!text.Contains('\r'))
|
||||
return text;
|
||||
|
||||
var builder = new StringBuilder(text.Length);
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (c is not ('\r' or '\n'))
|
||||
{
|
||||
builder.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('\n');
|
||||
if (i + 1 < text.Length && text[i + 1] is '\r' or '\n' && text[i + 1] != c)
|
||||
i++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string ReadBracketedString()
|
||||
{
|
||||
this.Expect('[');
|
||||
var result = this.ReadString();
|
||||
this.Expect(']');
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ReadString()
|
||||
{
|
||||
this.SkipTrivia();
|
||||
var quote = this.Peek();
|
||||
if (quote == '[')
|
||||
{
|
||||
this.position++;
|
||||
var equalsStart = this.position;
|
||||
while (this.Peek() == '=')
|
||||
this.position++;
|
||||
var equals = this.source[equalsStart..this.position];
|
||||
if (this.Peek() != '[')
|
||||
throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), '[', this.position + 1));
|
||||
this.position++;
|
||||
|
||||
// Like Lua, a line break right after the opening bracket is not part of the string:
|
||||
this.SkipLineBreak();
|
||||
var end = this.source.IndexOf("]" + equals + "]", this.position, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
throw new FormatException(TB("A long string is not closed."));
|
||||
var value = NormalizeLineBreaks(this.source[this.position..end]);
|
||||
this.position = end + equals.Length + 2;
|
||||
return value;
|
||||
}
|
||||
if (quote is not ('"' or '\''))
|
||||
throw new FormatException(string.Format(TB("Expected a quoted string at character {0}."), this.position + 1));
|
||||
this.position++;
|
||||
var builder = new StringBuilder();
|
||||
while (this.position < this.source.Length)
|
||||
{
|
||||
var c = this.source[this.position++];
|
||||
if (c == quote)
|
||||
return builder.ToString();
|
||||
if (c is '\r' or '\n')
|
||||
throw new FormatException(TB("A quoted string contains a line break, which Lua does not allow."));
|
||||
if (c != '\\')
|
||||
{
|
||||
builder.Append(c);
|
||||
continue;
|
||||
}
|
||||
if (this.position == this.source.Length)
|
||||
break;
|
||||
c = this.source[this.position++];
|
||||
builder.Append(c switch
|
||||
{
|
||||
'n' => '\n', 'r' => '\r', 't' => '\t', 'a' => '\a', 'b' => '\b', 'f' => '\f', 'v' => '\v',
|
||||
'\\' => '\\', '"' => '"', '\'' => '\'',
|
||||
_ => throw new FormatException(string.Format(TB("The escape sequence {0} is not supported."), "\\" + c)),
|
||||
});
|
||||
}
|
||||
throw new FormatException(TB("A quoted string is not closed."));
|
||||
}
|
||||
|
||||
private void SkipTrivia()
|
||||
{
|
||||
while (this.position < this.source.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(this.source[this.position]))
|
||||
{
|
||||
this.position++;
|
||||
continue;
|
||||
}
|
||||
if (this.source.AsSpan(this.position).StartsWith("--"))
|
||||
{
|
||||
//
|
||||
// A block comment may end in the middle of a line and continue with data, which a
|
||||
// line comment would swallow. Rather than reading it differently than Lua does, it
|
||||
// is refused:
|
||||
//
|
||||
if (this.IsLongBracketAt(this.position + 2))
|
||||
throw new FormatException(string.Format(TB("The block comment at character {0} is not supported. Please use line comments, which start with -- and end at the end of the line."), this.position + 1));
|
||||
|
||||
this.position += 2;
|
||||
while (this.position < this.source.Length && this.source[this.position] != '\n')
|
||||
this.position++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private char Peek() => this.position < this.source.Length ? this.source[this.position] : '\0';
|
||||
|
||||
private bool Take(char c)
|
||||
{
|
||||
this.SkipTrivia();
|
||||
if (this.Peek() != c)
|
||||
return false;
|
||||
this.position++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Expect(char c)
|
||||
{
|
||||
if (!this.Take(c))
|
||||
throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), c, this.position + 1));
|
||||
}
|
||||
|
||||
private bool TakeWord(string word)
|
||||
{
|
||||
this.SkipTrivia();
|
||||
if (!this.source.AsSpan(this.position).StartsWith(word) ||
|
||||
(this.position + word.Length < this.source.Length && (char.IsLetterOrDigit(this.source[this.position + word.Length]) || this.source[this.position + word.Length] == '_')))
|
||||
return false;
|
||||
this.position += word.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ExpectWord(string word)
|
||||
{
|
||||
if (!this.TakeWord(word))
|
||||
throw new FormatException(string.Format(TB("Expected '{0}' at character {1}."), word, this.position + 1));
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -1,3 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
@@ -18,7 +20,18 @@ public sealed class DataSourceConfigurationObjectDescriptor : PluginConfiguratio
|
||||
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);
|
||||
public override string LuaTableName => "DATA_SOURCES";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryParse(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun, string pluginPath) => PluginConfigurationObject.TryParseDataSources(this, mainTable, configPluginId, configObjects, dryRun);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject)
|
||||
{
|
||||
var isValid = DataSourceERI_V1.TryParseConfiguration(1, table, Guid.Empty, dryRun: true, out var dataSource);
|
||||
configObject = isValid ? dataSource : null;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
|
||||
@@ -263,6 +263,30 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: allow the user to add mailboxes?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddMailbox, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import configurations which others exported?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationSnippets, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import profiles?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportProfile, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import LLM providers?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportLLMProvider, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import embedding providers?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportEmbeddingProvider, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import transcription providers?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportTranscriptionProvider, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import chat templates?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportChatTemplate, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import external data sources (ERI servers)?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportERIDataSource, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import document analysis policies?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportDocumentAnalysisPolicy, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import plugin archives?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -819,4 +843,4 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
LOG.LogWarning("The table 'MAILBOX_PROVIDERS' entry at index {Index} does not contain a valid mail server (config plugin id: {ConfigPluginId}).", i, this.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,17 +282,18 @@ public sealed record PluginConfigurationObject
|
||||
/// <summary>
|
||||
/// Parses configured data sources from a configuration plugin.
|
||||
/// </summary>
|
||||
/// <param name="descriptor">The descriptor of the data sources.</param>
|
||||
/// <param name="mainTable">The Lua table containing entries to parse into data sources.</param>
|
||||
/// <param name="configPluginId">The unique identifier of the plugin associated with the data sources.</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.</param>
|
||||
/// <returns>True if the table was present and processed; otherwise false.</returns>
|
||||
public static bool TryParseDataSources(LuaTable mainTable, Guid configPluginId, List<PluginConfigurationObject> configObjects, bool dryRun)
|
||||
public static bool TryParseDataSources(DataSourceConfigurationObjectDescriptor descriptor, 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))
|
||||
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}).", LUA_TABLE_NAME, configPluginId);
|
||||
LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -305,13 +306,13 @@ public sealed record PluginConfigurationObject
|
||||
var luaObjectTableValue = luaTable[i];
|
||||
if (!luaObjectTableValue.TryRead<LuaTable>(out var luaObjectTable))
|
||||
{
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId);
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, dryRun, out var configObject))
|
||||
{
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId);
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -653,4 +654,4 @@ public sealed record PluginConfigurationObject
|
||||
var candidatePath = Path.GetFullPath(Path.Combine(fullPluginPath, configuredTokenizerPath));
|
||||
return PathTools.IsInsideDirectory(fullPluginPath, candidatePath) ? candidatePath : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
@@ -22,6 +24,11 @@ public abstract class PluginConfigurationObjectDescriptor
|
||||
/// </summary>
|
||||
public abstract PluginConfigurationObjectType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The Lua table in which a configuration plugin defines these objects, e.g., LLM_PROVIDERS.
|
||||
/// </summary>
|
||||
public abstract string LuaTableName { 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.
|
||||
@@ -34,6 +41,19 @@ public abstract class PluginConfigurationObjectDescriptor
|
||||
/// <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>
|
||||
/// Reads the configuration object of a configuration snippet, which belongs to no configuration
|
||||
/// plugin. It is read as a dry run, so neither the object nor its secret is stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without the directory of a plugin, a path relative to it cannot be resolved, so the parsers
|
||||
/// leave out what such a path points to, e.g., a custom icon.
|
||||
/// </remarks>
|
||||
/// <param name="table">The table which the snippet assigns.</param>
|
||||
/// <param name="configObject">The configuration object read from the table.</param>
|
||||
/// <returns>True when the table defines a valid configuration object.</returns>
|
||||
public abstract bool TryParseSnippet(LuaTable table, [NotNullWhen(true)] out IConfigurationObject? configObject);
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
@@ -43,6 +45,26 @@ public static class PluginConfigurationObjectDescriptors
|
||||
DOCUMENT_ANALYSIS_POLICY,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The descriptor of a type of configuration object.
|
||||
/// </summary>
|
||||
/// <param name="type">The type, which must not be NONE or UNKNOWN.</param>
|
||||
/// <returns>The descriptor of the type.</returns>
|
||||
public static PluginConfigurationObjectDescriptor Get(PluginConfigurationObjectType type) =>
|
||||
ALL.FirstOrDefault(descriptor => descriptor.Type == type) ?? throw new ArgumentOutOfRangeException(nameof(type), type, "This type of configuration object has no descriptor.");
|
||||
|
||||
/// <summary>
|
||||
/// Finds the descriptor of the configuration objects which a Lua table holds.
|
||||
/// </summary>
|
||||
/// <param name="luaTableName">The name of the Lua table, e.g., LLM_PROVIDERS.</param>
|
||||
/// <param name="descriptor">The descriptor, when one uses this table.</param>
|
||||
/// <returns>True when a descriptor uses this table.</returns>
|
||||
public static bool TryGet(string luaTableName, [NotNullWhen(true)] out PluginConfigurationObjectDescriptor? descriptor)
|
||||
{
|
||||
descriptor = ALL.FirstOrDefault(candidate => candidate.LuaTableName == luaTableName);
|
||||
return descriptor is not null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class PluginConfigurationObjectTypeExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfigurationObjectTypeExtensions).Namespace, nameof(PluginConfigurationObjectTypeExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// The name of a type of configuration object, as users read it, e.g., in a message.
|
||||
/// </summary>
|
||||
public static string GetName(this PluginConfigurationObjectType type) => type switch
|
||||
{
|
||||
PluginConfigurationObjectType.LLM_PROVIDER => TB("LLM Provider"),
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER => TB("Embedding Provider"),
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => TB("Transcription Provider"),
|
||||
PluginConfigurationObjectType.PROFILE => TB("Profile"),
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE => TB("Chat Template"),
|
||||
PluginConfigurationObjectType.DATA_SOURCE => TB("Data Source"),
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => TB("Document Analysis Policy"),
|
||||
|
||||
_ => TB("Unknown configuration"),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user