mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-12 09:53:48 +00:00
Merge branch 'main' into chunk-data
This commit is contained in:
commit
2e06af13e0
133 files changed
+3704
-968
No files matched your search
@@ -0,0 +1,18 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents complex content from a configuration plugin that is read live from
|
||||
/// running plugins and is not persisted to the settings data model.
|
||||
/// </summary>
|
||||
public interface ILivePluginContent
|
||||
{
|
||||
/// <summary>
|
||||
/// The stable ID of the live plugin content.
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The ID of the enterprise configuration plugin that provides this content.
|
||||
/// </summary>
|
||||
public Guid EnterpriseConfigurationPluginId { get; }
|
||||
}
|
||||
@@ -9,11 +9,12 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginType type) : PluginBase(isInternal, state, type)
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration));
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration));
|
||||
|
||||
private List<PluginConfigurationObject> configObjects = [];
|
||||
private List<DataMandatoryInfo> mandatoryInfos = [];
|
||||
private List<DataIntroduction> introductions = [];
|
||||
|
||||
/// <summary>
|
||||
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
|
||||
@@ -22,9 +23,16 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
|
||||
/// <summary>
|
||||
/// The list of mandatory infos provided by this configuration plugin.
|
||||
/// Mandatory infos are live plugin content and are not persisted to ConfigurationData.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DataMandatoryInfo> MandatoryInfos => this.mandatoryInfos;
|
||||
|
||||
/// <summary>
|
||||
/// The list of introductions provided by this configuration plugin.
|
||||
/// Introductions are live plugin content and are not persisted to ConfigurationData.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DataIntroduction> Introductions => this.introductions;
|
||||
|
||||
/// <summary>
|
||||
/// True/false when explicitly configured in the plugin, otherwise null.
|
||||
/// </summary>
|
||||
@@ -43,7 +51,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
await StoreEnterpriseApiKeysAsync();
|
||||
await StoreEnterpriseSecretsAsync();
|
||||
|
||||
await SETTINGS_MANAGER.StoreSettings();
|
||||
await SettingsManagerAccess.StoreSettings();
|
||||
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +140,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
{
|
||||
this.configObjects.Clear();
|
||||
this.mandatoryInfos.Clear();
|
||||
this.introductions.Clear();
|
||||
|
||||
// Ensure that the main CONFIG table exists and is a valid Lua table:
|
||||
if (!this.State.Environment["CONFIG"].TryRead<LuaTable>(out var mainTable))
|
||||
@@ -156,6 +165,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: what should be the start page?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: show built-in introduction on the home page?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowIntroduction, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: show quick start guide on the home page?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowQuickStartGuide, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -184,6 +196,16 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificatesEnabled, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificateBundlePath, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ExternalHttpCustomRootCertificateAllowedHosts, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: provider confidence settings
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.EnforceGlobalMinimumConfidence, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.GlobalMinimumConfidence, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.ShowProviderConfidence, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.ConfidenceScheme, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Confidence, x => x.CustomConfidenceScheme, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: data source security settings
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Handle configured LLM providers:
|
||||
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);
|
||||
@@ -208,6 +230,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
|
||||
// Handle configured mandatory infos:
|
||||
this.TryReadMandatoryInfos(mainTable);
|
||||
|
||||
// Handle configured introductions:
|
||||
this.TryReadIntroductions(mainTable);
|
||||
|
||||
// Config: preselected provider?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
|
||||
@@ -215,6 +240,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: preselected profile?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProfile, Guid.Empty, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: preselected chat options?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectOptions, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProfile, this.Id, settingsTable, dryRun);
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedChatTemplate, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: transcription provider?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -242,4 +273,25 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
LOG.LogWarning("The table 'MANDATORY_INFOS' entry at index {Index} does not contain a valid mandatory info (config plugin id: {ConfigPluginId}).", i, this.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryReadIntroductions(LuaTable mainTable)
|
||||
{
|
||||
if (!mainTable.TryGetValue("INTRODUCTIONS", out var introductionsValue) || !introductionsValue.TryRead<LuaTable>(out var introductionsTable))
|
||||
return;
|
||||
|
||||
for (var i = 1; i <= introductionsTable.ArrayLength; i++)
|
||||
{
|
||||
var luaIntroductionValue = introductionsTable[i];
|
||||
if (!luaIntroductionValue.TryRead<LuaTable>(out var luaIntroductionTable))
|
||||
{
|
||||
LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", i, this.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DataIntroduction.TryParseConfiguration(i, luaIntroductionTable, this.Id, out var introduction))
|
||||
this.introductions.Add(introduction);
|
||||
else
|
||||
LOG.LogWarning("The table 'INTRODUCTIONS' entry at index {Index} does not contain a valid introduction (config plugin id: {ConfigPluginId}).", i, this.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,9 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
/// </summary>
|
||||
public sealed record PluginConfigurationObject
|
||||
{
|
||||
private static readonly RustService RUST_SERVICE = Program.SERVICE_PROVIDER.GetRequiredService<RustService>();
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
private static RustService RustService => Program.SERVICE_PROVIDER.GetRequiredService<RustService>();
|
||||
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
private static ThreadSafeRandom Rng => Program.SERVICE_PROVIDER.GetRequiredService<ThreadSafeRandom>();
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger<PluginConfigurationObject>();
|
||||
|
||||
/// <summary>
|
||||
@@ -92,7 +93,8 @@ public sealed record PluginConfigurationObject
|
||||
return false;
|
||||
}
|
||||
|
||||
var storedObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData);
|
||||
var localSettingsManager = SettingsManagerAccess;
|
||||
var storedObjects = configObjectSelection.Compile()(localSettingsManager.ConfigurationData);
|
||||
var numberObjects = luaTable.ArrayLength;
|
||||
ThreadSafeRandom? random = null;
|
||||
for (var i = 1; i <= numberObjects; i++)
|
||||
@@ -142,7 +144,7 @@ public sealed record PluginConfigurationObject
|
||||
// Case: The object does not exist, we have to add it
|
||||
else
|
||||
{
|
||||
if (nextConfigObjectNumSelection.TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST) is { Success: true, UpdatedValue: var nextNum })
|
||||
if (nextConfigObjectNumSelection.TryIncrement(localSettingsManager.ConfigurationData, IncrementType.POST) is { Success: true, UpdatedValue: var nextNum })
|
||||
{
|
||||
// Case: Increment the next number was successful
|
||||
configObject = configObject with { Num = nextNum };
|
||||
@@ -151,7 +153,7 @@ public sealed record PluginConfigurationObject
|
||||
else
|
||||
{
|
||||
// Case: The next number could not be incremented, we use a random number
|
||||
random ??= new ThreadSafeRandom();
|
||||
random ??= Rng;
|
||||
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
||||
storedObjects.Add((TClass)configObject);
|
||||
LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId);
|
||||
@@ -222,7 +224,8 @@ public sealed record PluginConfigurationObject
|
||||
return false;
|
||||
}
|
||||
|
||||
var storedObjects = SETTINGS_MANAGER.ConfigurationData.DataSources;
|
||||
var localSettingsManager = SettingsManagerAccess;
|
||||
var storedObjects = localSettingsManager.ConfigurationData.DataSources;
|
||||
var numberObjects = luaTable.ArrayLength;
|
||||
ThreadSafeRandom? random = null;
|
||||
for (var i = 1; i <= numberObjects; i++)
|
||||
@@ -259,14 +262,14 @@ public sealed record PluginConfigurationObject
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IncrementDataSourceNum() is { Success: true, UpdatedValue: var nextNum })
|
||||
if (IncrementDataSourceNum(localSettingsManager.ConfigurationData) is { Success: true, UpdatedValue: var nextNum })
|
||||
{
|
||||
configObject = configObject with { Num = nextNum };
|
||||
storedObjects.Add(configObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
random ??= new ThreadSafeRandom();
|
||||
random ??= Rng;
|
||||
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
||||
storedObjects.Add(configObject);
|
||||
LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId);
|
||||
@@ -276,9 +279,9 @@ public sealed record PluginConfigurationObject
|
||||
|
||||
return true;
|
||||
|
||||
static IncrementResult<uint> IncrementDataSourceNum()
|
||||
static IncrementResult<uint> IncrementDataSourceNum(Data data)
|
||||
{
|
||||
return ((Expression<Func<Data, uint>>)(x => x.NextDataSourceNum)).TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST);
|
||||
return ((Expression<Func<Data, uint>>)(x => x.NextDataSourceNum)).TryIncrement(data, IncrementType.POST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +304,8 @@ public sealed record PluginConfigurationObject
|
||||
SecretStoreType? secretStoreType = null,
|
||||
bool deleteSecret = false) where TClass : IConfigurationObject
|
||||
{
|
||||
var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData);
|
||||
var localSettingsManager = SettingsManagerAccess;
|
||||
var configuredObjects = configObjectSelection.Compile()(localSettingsManager.ConfigurationData);
|
||||
var leftOverObjects = new List<TClass>();
|
||||
foreach (var configuredObject in configuredObjects)
|
||||
{
|
||||
@@ -357,7 +361,7 @@ public sealed record PluginConfigurationObject
|
||||
// Delete the API key from the OS keyring if the removed object has one:
|
||||
if(deleteSecret && item is ISecretId regularSecretId)
|
||||
{
|
||||
var deleteResult = await RUST_SERVICE.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE);
|
||||
var deleteResult = await RustService.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE);
|
||||
if (deleteResult.Success)
|
||||
LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring.");
|
||||
else
|
||||
@@ -365,7 +369,7 @@ public sealed record PluginConfigurationObject
|
||||
}
|
||||
else if(secretStoreType is not null && item is ISecretId secretId)
|
||||
{
|
||||
var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value);
|
||||
var deleteResult = await RustService.DeleteAPIKey(secretId, secretStoreType.Value);
|
||||
if (deleteResult.Success)
|
||||
LOG.LogInformation($"Successfully deleted API key for removed enterprise provider '{item.Name}' from the OS keyring.");
|
||||
else
|
||||
|
||||
@@ -191,7 +191,7 @@ public static partial class PluginFactory
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check left-over mandatory info acceptances:
|
||||
if (SETTINGS_MANAGER.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
|
||||
if (SettingsManagerAccess.ConfigurationData.MandatoryInformation.RemoveLeftOverAcceptances(GetMandatoryInfos()))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for a preselected provider:
|
||||
@@ -201,6 +201,19 @@ public static partial class PluginFactory
|
||||
// Check for a preselected profile:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProfile, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for preselected chat options:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectOptions, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProvider, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedProfile, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedChatTemplate, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for the update interval:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInterval, AVAILABLE_PLUGINS))
|
||||
@@ -214,6 +227,10 @@ public static partial class PluginFactory
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.StartPage, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for the built-in introduction visibility:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowIntroduction, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check for the quick start guide visibility:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowQuickStartGuide, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
@@ -263,6 +280,26 @@ public static partial class PluginFactory
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ExternalHttpCustomRootCertificateAllowedHosts, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check provider confidence settings:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.EnforceGlobalMinimumConfidence, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.GlobalMinimumConfidence, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ShowProviderConfidence, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.ConfidenceScheme, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Confidence, x => x.CustomConfidenceScheme, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check data source security settings:
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.DataSourceSecurity, x => x.TrustedProviderIds, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
|
||||
// Check if audit is required before it can be activated
|
||||
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS))
|
||||
wasConfigurationChanged = true;
|
||||
@@ -285,7 +322,7 @@ public static partial class PluginFactory
|
||||
|
||||
if (wasConfigurationChanged)
|
||||
{
|
||||
await SETTINGS_MANAGER.StoreSettings();
|
||||
await SettingsManagerAccess.StoreSettings();
|
||||
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public static partial class PluginFactory
|
||||
|
||||
try
|
||||
{
|
||||
if (availablePlugin.IsInternal || SETTINGS_MANAGER.IsPluginEnabled(availablePlugin) || availablePlugin.Type == PluginType.CONFIGURATION || availablePlugin.Type == PluginType.ASSISTANT)
|
||||
if (availablePlugin.IsInternal || SettingsManagerAccess.IsPluginEnabled(availablePlugin) || availablePlugin.Type == PluginType.CONFIGURATION || availablePlugin.Type == PluginType.ASSISTANT)
|
||||
if(await Start(availablePlugin, cancellationToken) is { IsValid: true } plugin)
|
||||
{
|
||||
if (plugin is PluginConfiguration configPlugin)
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
|
||||
private static string DATA_DIR = string.Empty;
|
||||
private static string PLUGINS_ROOT = string.Empty;
|
||||
@@ -136,4 +136,13 @@ public static partial class PluginFactory
|
||||
.SelectMany(plugin => plugin.MandatoryInfos)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DataIntroduction> GetIntroductions()
|
||||
{
|
||||
return RUNNING_PLUGINS
|
||||
.OfType<PluginConfiguration>()
|
||||
.SelectMany(plugin => plugin.Introductions)
|
||||
.OrderBy(introduction => introduction.Index)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user