mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2025-04-27 15:59:47 +00:00
Added plugin architecture (#322)
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (linux-arm64) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (linux-arm64) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
97454c59e9
commit
ee2a73ccd8
@ -1,12 +0,0 @@
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Data about a workspace.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the workspace.</param>
|
||||
public sealed class Workspace(string name)
|
||||
{
|
||||
public string Name { get; set; } = name;
|
||||
|
||||
public List<ChatThread> Threads { get; set; } = new();
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
@ -81,6 +82,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDis
|
||||
// Ensure that all settings are loaded:
|
||||
await this.SettingsManager.LoadSettings();
|
||||
|
||||
// Ensure that all internal plugins are present:
|
||||
await PluginFactory.EnsureInternalPlugins();
|
||||
|
||||
// Register this component with the message bus:
|
||||
this.MessageBus.RegisterComponent(this);
|
||||
this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.USER_SEARCH_FOR_UPDATE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED ]);
|
||||
|
@ -41,8 +41,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed all files in wwwroot folder -->
|
||||
<EmbeddedResource Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Plugins\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -52,6 +52,7 @@
|
||||
<PackageReference Include="MudBlazor" Version="8.4.0" />
|
||||
<PackageReference Include="MudBlazor.Markdown" Version="8.0.0" />
|
||||
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
|
||||
<PackageReference Include="LuaCSharp" Version="0.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -108,6 +108,7 @@
|
||||
<ThirdPartyComponent Name="base64" Developer="Marshall Pierce, Alice Maz & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/marshallpierce/rust-base64/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/marshallpierce/rust-base64" UseCase="For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose."/>
|
||||
<ThirdPartyComponent Name="Rust Crypto" Developer="Artyom Pavlov, Tony Arcieri, Brian Warner, Arthur Gautier, Vlad Filippov, Friedel Ziegelmayer, Nicolas Stalder & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/RustCrypto/traits/blob/master/cipher/LICENSE-MIT" RepositoryUrl="https://github.com/RustCrypto" UseCase="When transferring sensitive data between Rust runtime and .NET app, we encrypt the data. We use some libraries from the Rust Crypto project for this purpose: cipher, aes, cbc, pbkdf2, hmac, and sha2. We are thankful for the great work of the Rust Crypto project."/>
|
||||
<ThirdPartyComponent Name="rcgen" Developer="RustTLS developers, est31 & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rustls/rcgen/blob/main/LICENSE" RepositoryUrl="https://github.com/rustls/rcgen" UseCase="For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose."/>
|
||||
<ThirdPartyComponent Name="Lua-CSharp" Developer="Yusuke Nakada & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/nuskey8/Lua-CSharp/blob/main/LICENSE" RepositoryUrl="https://github.com/nuskey8/Lua-CSharp" UseCase="We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library." />
|
||||
<ThirdPartyComponent Name="HtmlAgilityPack" Developer="ZZZ Projects & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE" RepositoryUrl="https://github.com/zzzprojects/html-agility-pack" UseCase="We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant."/>
|
||||
<ThirdPartyComponent Name="ReverseMarkdown" Developer="Babu Annamalai & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mysticmind/reversemarkdown-net/blob/master/LICENSE" RepositoryUrl="https://github.com/mysticmind/reversemarkdown-net" UseCase="This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant."/>
|
||||
<ThirdPartyComponent Name="wikEd diff" Developer="Cacycle & Open Source Community" LicenseName="None (public domain)" LicenseUrl="https://en.wikipedia.org/wiki/User:Cacycle/diff#License" RepositoryUrl="https://en.wikipedia.org/wiki/User:Cacycle/diff" UseCase="This library is used to display the differences between two texts. This is necessary, e.g., for the grammar and spelling assistant."/>
|
||||
|
@ -0,0 +1,3 @@
|
||||
CONTENT_HOME = {
|
||||
LetsGetStarted = "Lass uns anfangen",
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
-- The ID for this plugin:
|
||||
ID = "43065dbc-78d0-45b7-92be-f14c2926e2dc"
|
||||
|
||||
-- The name of the plugin:
|
||||
NAME = "MindWork AI Studio - German / Deutsch"
|
||||
|
||||
-- The description of the plugin:
|
||||
DESCRIPTION = "Dieses Plugin bietet deutsche Sprachunterstützung für MindWork AI Studio."
|
||||
|
||||
-- The version of the plugin:
|
||||
VERSION = "1.0.0"
|
||||
|
||||
-- The type of the plugin:
|
||||
TYPE = "LANGUAGE"
|
||||
|
||||
-- The authors of the plugin:
|
||||
AUTHORS = {"MindWork AI Community"}
|
||||
|
||||
-- The support contact for the plugin:
|
||||
SUPPORT_CONTACT = "MindWork AI Community"
|
||||
|
||||
-- The source URL for the plugin:
|
||||
SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio"
|
||||
|
||||
-- The categories for the plugin:
|
||||
CATEGORIES = { "CORE" }
|
||||
|
||||
-- The target groups for the plugin:
|
||||
TARGET_GROUPS = { "EVERYONE" }
|
||||
|
||||
-- The flag for whether the plugin is maintained:
|
||||
IS_MAINTAINED = true
|
||||
|
||||
-- When the plugin is deprecated, this message will be shown to users:
|
||||
DEPRECATION_MESSAGE = nil
|
||||
|
||||
UI_TEXT_CONTENT = {
|
||||
HOME = CONTENT_HOME,
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
CONTENT_HOME = {
|
||||
LetsGetStarted = "Let's get started",
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
require("contentHome")
|
||||
|
||||
-- The ID for this plugin:
|
||||
ID = "97dfb1ba-50c4-4440-8dfa-6575daf543c8"
|
||||
|
||||
-- The name of the plugin:
|
||||
NAME = "MindWork AI Studio - US English"
|
||||
|
||||
-- The description of the plugin:
|
||||
DESCRIPTION = "This plugin provides US English language support for MindWork AI Studio."
|
||||
|
||||
-- The version of the plugin:
|
||||
VERSION = "1.0.0"
|
||||
|
||||
-- The type of the plugin:
|
||||
TYPE = "LANGUAGE"
|
||||
|
||||
-- The authors of the plugin:
|
||||
AUTHORS = {"MindWork AI Community"}
|
||||
|
||||
-- The support contact for the plugin:
|
||||
SUPPORT_CONTACT = "MindWork AI Community"
|
||||
|
||||
-- The source URL for the plugin:
|
||||
SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio"
|
||||
|
||||
-- The categories for the plugin:
|
||||
CATEGORIES = { "CORE" }
|
||||
|
||||
-- The target groups for the plugin:
|
||||
TARGET_GROUPS = { "EVERYONE" }
|
||||
|
||||
-- The flag for whether the plugin is maintained:
|
||||
IS_MAINTAINED = true
|
||||
|
||||
-- When the plugin is deprecated, this message will be shown to users:
|
||||
DEPRECATION_MESSAGE = nil
|
||||
|
||||
UI_TEXT_CONTENT = {
|
||||
HOME = CONTENT_HOME,
|
||||
}
|
@ -22,6 +22,7 @@ internal sealed class Program
|
||||
public static Encryption ENCRYPTION = null!;
|
||||
public static string API_TOKEN = null!;
|
||||
public static IServiceProvider SERVICE_PROVIDER = null!;
|
||||
public static ILoggerFactory LOGGER_FACTORY = null!;
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
@ -147,6 +148,9 @@ internal sealed class Program
|
||||
// Execute the builder to get the app:
|
||||
var app = builder.Build();
|
||||
|
||||
// Get the logging factory for e.g., static classes:
|
||||
LOGGER_FACTORY = app.Services.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
// Get a program logger:
|
||||
var programLogger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
programLogger.LogInformation("Starting the AI Studio server.");
|
||||
|
@ -8,4 +8,6 @@ public enum PreviewFeatures
|
||||
//
|
||||
PRE_WRITER_MODE_2024,
|
||||
PRE_RAG_2024,
|
||||
|
||||
PRE_PLUGINS_2025,
|
||||
}
|
@ -6,6 +6,7 @@ public static class PreviewFeaturesExtensions
|
||||
{
|
||||
PreviewFeatures.PRE_WRITER_MODE_2024 => "Writer Mode: Experiments about how to write long texts using AI",
|
||||
PreviewFeatures.PRE_RAG_2024 => "RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company",
|
||||
PreviewFeatures.PRE_PLUGINS_2025 => "Plugins: Preview of our plugin system where you can extend the functionality of the app",
|
||||
|
||||
_ => "Unknown preview feature"
|
||||
};
|
||||
|
@ -25,6 +25,7 @@ public static class PreviewVisibilityExtensions
|
||||
if (visibility >= PreviewVisibility.EXPERIMENTAL)
|
||||
{
|
||||
features.Add(PreviewFeatures.PRE_WRITER_MODE_2024);
|
||||
features.Add(PreviewFeatures.PRE_PLUGINS_2025);
|
||||
}
|
||||
|
||||
return features;
|
||||
|
22
app/MindWork AI Studio/Tools/CommonTools.cs
Normal file
22
app/MindWork AI Studio/Tools/CommonTools.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class CommonTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all the values (the names) of an enum as a string, separated by commas.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type to get the values of.</typeparam>
|
||||
/// <param name="exceptions">The values to exclude from the result.</param>
|
||||
/// <returns>The values of the enum as a string, separated by commas.</returns>
|
||||
public static string GetAllEnumValues<TEnum>(params TEnum[] exceptions) where TEnum : struct, Enum
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var value in Enum.GetValues<TEnum>())
|
||||
if(!exceptions.Contains(value))
|
||||
sb.Append(value).Append(", ");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a plugin is forbidden.
|
||||
/// </summary>
|
||||
public static class ForbiddenPlugins
|
||||
{
|
||||
private const string ID_PATTERN = "ID = \"";
|
||||
private static readonly int ID_PATTERN_LEN = ID_PATTERN.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given code represents a forbidden plugin.
|
||||
/// </summary>
|
||||
/// <param name="code">The code to check.</param>
|
||||
/// <returns>The result of the check.</returns>
|
||||
public static PluginCheckResult Check(ReadOnlySpan<char> code)
|
||||
{
|
||||
var endIndex = 0;
|
||||
var foundAnyId = false;
|
||||
var id = ReadOnlySpan<char>.Empty;
|
||||
while (true)
|
||||
{
|
||||
// Create a slice of the code starting at the end index.
|
||||
// This way we can search for all IDs in the code:
|
||||
code = code[endIndex..];
|
||||
|
||||
// Read the next ID as a string:
|
||||
if (!TryGetId(code, out id, out endIndex))
|
||||
{
|
||||
// When no ID was found at all, we block this plugin.
|
||||
// When another ID was found previously, we allow this plugin.
|
||||
if(foundAnyId)
|
||||
return new PluginCheckResult(false, null);
|
||||
|
||||
return new PluginCheckResult(true, "No ID was found.");
|
||||
}
|
||||
|
||||
// Try to parse the ID as a GUID:
|
||||
if (!Guid.TryParse(id, out var parsedGuid))
|
||||
{
|
||||
// Again, when no ID was found at all, we block this plugin.
|
||||
if(foundAnyId)
|
||||
return new PluginCheckResult(false, null);
|
||||
|
||||
return new PluginCheckResult(true, "The ID is not a valid GUID.");
|
||||
}
|
||||
|
||||
// Check if the GUID is forbidden:
|
||||
if (FORBIDDEN_PLUGINS.TryGetValue(parsedGuid, out var reason))
|
||||
return new PluginCheckResult(true, reason);
|
||||
|
||||
foundAnyId = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetId(ReadOnlySpan<char> code, out ReadOnlySpan<char> id, out int endIndex)
|
||||
{
|
||||
//
|
||||
// Please note: the code variable is a slice of the original code.
|
||||
// That means the indices are relative to the slice, not the original code.
|
||||
//
|
||||
|
||||
id = ReadOnlySpan<char>.Empty;
|
||||
endIndex = 0;
|
||||
|
||||
// Find the next ID:
|
||||
var idStartIndex = code.IndexOf(ID_PATTERN);
|
||||
if (idStartIndex < 0)
|
||||
return false;
|
||||
|
||||
// Find the start index of the value (Guid):
|
||||
var valueStartIndex = idStartIndex + ID_PATTERN_LEN;
|
||||
|
||||
// Find the end index of the value. In order to do that,
|
||||
// we create a slice of the code starting at the value
|
||||
// start index. That means that the end index is relative
|
||||
// to the inner slice, not the original code nor the outer slice.
|
||||
var valueEndIndex = code[valueStartIndex..].IndexOf('"');
|
||||
if (valueEndIndex < 0)
|
||||
return false;
|
||||
|
||||
// From the perspective of the start index is the end index
|
||||
// the length of the value:
|
||||
endIndex = valueStartIndex + valueEndIndex;
|
||||
id = code.Slice(valueStartIndex, valueEndIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The forbidden plugins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A dictionary that maps the GUID of a plugin to the reason why it is forbidden.
|
||||
/// </remarks>
|
||||
// ReSharper disable once CollectionNeverUpdated.Local
|
||||
private static readonly Dictionary<Guid, string> FORBIDDEN_PLUGINS =
|
||||
[
|
||||
];
|
||||
}
|
21
app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs
Normal file
21
app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a contract for a language plugin.
|
||||
/// </summary>
|
||||
public interface ILanguagePlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to get a text from the language plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the key does not exist, the value will be an empty string.
|
||||
/// Please note that the key is case-sensitive. Furthermore, the keys
|
||||
/// are in the format "root::key". That means that the keys are
|
||||
/// hierarchical and separated by "::".
|
||||
/// </remarks>
|
||||
/// <param name="key">The key to use to get the text.</param>
|
||||
/// <param name="value">The desired text.</param>
|
||||
/// <returns>True if the key exists, false otherwise.</returns>
|
||||
public bool TryGetText(string key, out string value);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public enum InternalPlugin
|
||||
{
|
||||
LANGUAGE_EN_US,
|
||||
LANGUAGE_DE_DE,
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public readonly record struct InternalPluginData(PluginType Type, Guid Id, string ShortName)
|
||||
{
|
||||
public string ResourcePath => $"{this.Type.GetDirectory()}/{this.ShortName.ToLowerInvariant()}-{this.Id}";
|
||||
|
||||
public string ResourceName => $"{this.ShortName.ToLowerInvariant()}-{this.Id}";
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class InternalPluginExtensions
|
||||
{
|
||||
public static InternalPluginData MetaData(this InternalPlugin plugin) => plugin switch
|
||||
{
|
||||
InternalPlugin.LANGUAGE_EN_US => new (PluginType.LANGUAGE, new("97dfb1ba-50c4-4440-8dfa-6575daf543c8"), "en-us"),
|
||||
InternalPlugin.LANGUAGE_DE_DE => new(PluginType.LANGUAGE, new("43065dbc-78d0-45b7-92be-f14c2926e2dc"), "de-de"),
|
||||
|
||||
_ => new InternalPluginData(PluginType.NONE, Guid.Empty, "unknown")
|
||||
};
|
||||
}
|
20
app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs
Normal file
20
app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// This Lua module loader does not load any modules.
|
||||
/// </summary>
|
||||
public sealed class NoModuleLoader : ILuaModuleLoader
|
||||
{
|
||||
#region Implementation of ILuaModuleLoader
|
||||
|
||||
public bool Exists(string moduleName) => false;
|
||||
|
||||
public ValueTask<LuaModule> LoadAsync(string moduleName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(new LuaModule());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
9
app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs
Normal file
9
app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin that could not be loaded.
|
||||
/// </summary>
|
||||
/// <param name="parsingError">The error message that occurred while parsing the plugin.</param>
|
||||
public sealed class NoPlugin(string parsingError) : PluginBase(string.Empty, LuaState.Create(), PluginType.NONE, parsingError);
|
521
app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs
Normal file
521
app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs
Normal file
@ -0,0 +1,521 @@
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the base of any AI Studio plugin.
|
||||
/// </summary>
|
||||
public abstract class PluginBase
|
||||
{
|
||||
private readonly IReadOnlyCollection<string> baseIssues;
|
||||
protected readonly LuaState state;
|
||||
|
||||
protected readonly List<string> pluginIssues = [];
|
||||
|
||||
/// <summary>
|
||||
/// The type of this plugin.
|
||||
/// </summary>
|
||||
public PluginType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The ID of this plugin.
|
||||
/// </summary>
|
||||
public Guid Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of this plugin.
|
||||
/// </summary>
|
||||
public string Name { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The description of this plugin.
|
||||
/// </summary>
|
||||
public string Description { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The version of this plugin.
|
||||
/// </summary>
|
||||
public PluginVersion Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The authors of this plugin.
|
||||
/// </summary>
|
||||
public string[] Authors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The support contact for this plugin.
|
||||
/// </summary>
|
||||
public string SupportContact { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The source URL of this plugin.
|
||||
/// </summary>
|
||||
public string SourceURL { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The categories of this plugin.
|
||||
/// </summary>
|
||||
public PluginCategory[] Categories { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The target groups of this plugin.
|
||||
/// </summary>
|
||||
public PluginTargetGroup[] TargetGroups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// True, when the plugin is maintained.
|
||||
/// </summary>
|
||||
public bool IsMaintained { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The message that should be displayed when the plugin is deprecated.
|
||||
/// </summary>
|
||||
public string? DeprecationMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The issues that occurred during the initialization of this plugin.
|
||||
/// </summary>
|
||||
public IEnumerable<string> Issues => this.baseIssues.Concat(this.pluginIssues);
|
||||
|
||||
/// <summary>
|
||||
/// True, when the plugin is valid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// False means that there were issues during the initialization of the plugin.
|
||||
/// Please check the Issues property for more information.
|
||||
/// </remarks>
|
||||
public bool IsValid => this is not NoPlugin && this.baseIssues.Count == 0 && this.pluginIssues.Count == 0;
|
||||
|
||||
protected PluginBase(string path, LuaState state, PluginType type, string parseError = "")
|
||||
{
|
||||
this.state = state;
|
||||
this.Type = type;
|
||||
|
||||
// For security reasons, we don't want to allow the plugin to load modules:
|
||||
this.state.ModuleLoader = new NoModuleLoader();
|
||||
|
||||
// Add some useful libraries:
|
||||
this.state.OpenModuleLibrary();
|
||||
this.state.OpenStringLibrary();
|
||||
this.state.OpenTableLibrary();
|
||||
this.state.OpenMathLibrary();
|
||||
this.state.OpenBitwiseLibrary();
|
||||
this.state.OpenCoroutineLibrary();
|
||||
|
||||
// Add the module loader so that the plugin can load other Lua modules:
|
||||
this.state.ModuleLoader = new PluginLoader(path);
|
||||
|
||||
var issues = new List<string>();
|
||||
if(!string.IsNullOrWhiteSpace(parseError))
|
||||
issues.Add(parseError);
|
||||
|
||||
if(this.TryInitId(out var issue, out var id))
|
||||
this.Id = id;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitName(out issue, out var name))
|
||||
this.Name = name;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitDescription(out issue, out var description))
|
||||
this.Description = description;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitVersion(out issue, out var version))
|
||||
this.Version = version;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitAuthors(out issue, out var authors))
|
||||
this.Authors = authors;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitSupportContact(out issue, out var contact))
|
||||
this.SupportContact = contact;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitSourceURL(out issue, out var url))
|
||||
this.SourceURL = url;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitCategories(out issue, out var categories))
|
||||
this.Categories = categories;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitTargetGroups(out issue, out var targetGroups))
|
||||
this.TargetGroups = targetGroups;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitIsMaintained(out issue, out var isMaintained))
|
||||
this.IsMaintained = isMaintained;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
if(this.TryInitDeprecationMessage(out issue, out var deprecationMessage))
|
||||
this.DeprecationMessage = deprecationMessage;
|
||||
else if(this is not NoPlugin)
|
||||
issues.Add(issue);
|
||||
|
||||
this.baseIssues = issues;
|
||||
}
|
||||
|
||||
#region Initialization-related methods
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the ID of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the ID could not be read.</param>
|
||||
/// <param name="id">The read ID.</param>
|
||||
/// <returns>True, when the ID could be read successfully.</returns>
|
||||
private bool TryInitId(out string message, out Guid id)
|
||||
{
|
||||
if (!this.state.Environment["ID"].TryRead<string>(out var idText))
|
||||
{
|
||||
message = "The field ID does not exist or is not a valid string.";
|
||||
id = Guid.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(idText, out id))
|
||||
{
|
||||
message = "The field ID is not a valid GUID / UUID. The ID must be formatted in the 8-4-4-4-12 format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX).";
|
||||
id = Guid.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if(id == Guid.Empty)
|
||||
{
|
||||
message = "The field ID is empty. The ID must be formatted in the 8-4-4-4-12 format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX).";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the name of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the name could not be read.</param>
|
||||
/// <param name="name">The read name.</param>
|
||||
/// <returns>True, when the name could be read successfully.</returns>
|
||||
private bool TryInitName(out string message, out string name)
|
||||
{
|
||||
if (!this.state.Environment["NAME"].TryRead(out name))
|
||||
{
|
||||
message = "The field NAME does not exist or is not a valid string.";
|
||||
name = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
message = "The field NAME is empty. The name must be a non-empty string.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the description of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the description could not be read.</param>
|
||||
/// <param name="description">The read description.</param>
|
||||
/// <returns>True, when the description could be read successfully.</returns>
|
||||
private bool TryInitDescription(out string message, out string description)
|
||||
{
|
||||
if (!this.state.Environment["DESCRIPTION"].TryRead(out description))
|
||||
{
|
||||
message = "The field DESCRIPTION does not exist or is not a valid string.";
|
||||
description = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
message = "The field DESCRIPTION is empty. The description must be a non-empty string.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the version of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the version could not be read.</param>
|
||||
/// <param name="version">The read version.</param>
|
||||
/// <returns>True, when the version could be read successfully.</returns>
|
||||
private bool TryInitVersion(out string message, out PluginVersion version)
|
||||
{
|
||||
if (!this.state.Environment["VERSION"].TryRead<string>(out var versionText))
|
||||
{
|
||||
message = "The field VERSION does not exist or is not a valid string.";
|
||||
version = PluginVersion.NONE;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PluginVersion.TryParse(versionText, out version))
|
||||
{
|
||||
message = "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X).";
|
||||
version = PluginVersion.NONE;
|
||||
return false;
|
||||
}
|
||||
|
||||
if(version == PluginVersion.NONE)
|
||||
{
|
||||
message = "The field VERSION is empty. The version number must be formatted as string in the major.minor.patch format (X.X.X).";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the authors of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the authors could not be read.</param>
|
||||
/// <param name="authors">The read authors.</param>
|
||||
/// <returns>True, when the authors could be read successfully.</returns>
|
||||
private bool TryInitAuthors(out string message, out string[] authors)
|
||||
{
|
||||
if (!this.state.Environment["AUTHORS"].TryRead<LuaTable>(out var authorsTable))
|
||||
{
|
||||
authors = [];
|
||||
message = "The table AUTHORS does not exist or is using an invalid syntax.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var authorList = new List<string>();
|
||||
foreach(var author in authorsTable.GetArraySpan())
|
||||
if(author.TryRead<string>(out var authorName))
|
||||
authorList.Add(authorName);
|
||||
|
||||
authors = authorList.ToArray();
|
||||
if(authorList.Count == 0)
|
||||
{
|
||||
message = "The table AUTHORS is empty. At least one author must be specified.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the support contact for the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the support contact could not be read.</param>
|
||||
/// <param name="contact">The read support contact.</param>
|
||||
/// <returns>True, when the support contact could be read successfully.</returns>
|
||||
private bool TryInitSupportContact(out string message, out string contact)
|
||||
{
|
||||
if (!this.state.Environment["SUPPORT_CONTACT"].TryRead(out contact))
|
||||
{
|
||||
contact = string.Empty;
|
||||
message = "The field SUPPORT_CONTACT does not exist or is not a valid string.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(contact))
|
||||
{
|
||||
message = "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to read the source URL of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the source URL could not be read.</param>
|
||||
/// <param name="url">The read source URL.</param>
|
||||
/// <returns>True, when the source URL could be read successfully.</returns>
|
||||
private bool TryInitSourceURL(out string message, out string url)
|
||||
{
|
||||
if (!this.state.Environment["SOURCE_URL"].TryRead(out url))
|
||||
{
|
||||
url = string.Empty;
|
||||
message = "The field SOURCE_URL does not exist or is not a valid string.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) && !url.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
url = string.Empty;
|
||||
message = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the categories of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the categories could not be read.</param>
|
||||
/// <param name="categories">The read categories.</param>
|
||||
/// <returns>True, when the categories could be read successfully.</returns>
|
||||
private bool TryInitCategories(out string message, out PluginCategory[] categories)
|
||||
{
|
||||
if (!this.state.Environment["CATEGORIES"].TryRead<LuaTable>(out var categoriesTable))
|
||||
{
|
||||
categories = [];
|
||||
message = "The table CATEGORIES does not exist or is using an invalid syntax.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var categoryList = new List<PluginCategory>();
|
||||
foreach(var luaCategory in categoriesTable.GetArraySpan())
|
||||
if(luaCategory.TryRead<string>(out var categoryName))
|
||||
if(Enum.TryParse<PluginCategory>(categoryName, out var category) && category != PluginCategory.NONE)
|
||||
categoryList.Add(category);
|
||||
|
||||
categories = categoryList.ToArray();
|
||||
if(categoryList.Count == 0)
|
||||
{
|
||||
message = $"The table CATEGORIES is empty. At least one category is necessary. Valid categories are: {CommonTools.GetAllEnumValues(PluginCategory.NONE)}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the intended target groups for the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the target groups could not be read.</param>
|
||||
/// <param name="targetGroups">The read target groups.</param>
|
||||
/// <returns>True, when the target groups could be read successfully.</returns>
|
||||
private bool TryInitTargetGroups(out string message, out PluginTargetGroup[] targetGroups)
|
||||
{
|
||||
if (!this.state.Environment["TARGET_GROUPS"].TryRead<LuaTable>(out var targetGroupsTable))
|
||||
{
|
||||
targetGroups = [];
|
||||
message = "The table TARGET_GROUPS does not exist or is using an invalid syntax.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetGroupList = new List<PluginTargetGroup>();
|
||||
foreach(var luaTargetGroup in targetGroupsTable.GetArraySpan())
|
||||
if(luaTargetGroup.TryRead<string>(out var targetGroupName))
|
||||
if(Enum.TryParse<PluginTargetGroup>(targetGroupName, out var targetGroup) && targetGroup != PluginTargetGroup.NONE)
|
||||
targetGroupList.Add(targetGroup);
|
||||
|
||||
targetGroups = targetGroupList.ToArray();
|
||||
if(targetGroups.Length == 0)
|
||||
{
|
||||
message = "The table TARGET_GROUPS is empty or is not a valid table of strings. Valid target groups are: {CommonTools.GetAllEnumValues(PluginTargetGroup.NONE)}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the maintenance status of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the maintenance status could not be read.</param>
|
||||
/// <param name="isMaintained">The read maintenance status.</param>
|
||||
/// <returns>True, when the maintenance status could be read successfully.</returns>
|
||||
private bool TryInitIsMaintained(out string message, out bool isMaintained)
|
||||
{
|
||||
if (!this.state.Environment["IS_MAINTAINED"].TryRead(out isMaintained))
|
||||
{
|
||||
isMaintained = false;
|
||||
message = "The field IS_MAINTAINED does not exist or is not a valid boolean.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to read the deprecation message of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the deprecation message could not be read.</param>
|
||||
/// <param name="deprecationMessage">The read deprecation message.</param>
|
||||
/// <returns>True, when the deprecation message could be read successfully.</returns>
|
||||
private bool TryInitDeprecationMessage(out string message, out string? deprecationMessage)
|
||||
{
|
||||
if (!this.state.Environment["DEPRECATION_MESSAGE"].TryRead(out deprecationMessage))
|
||||
{
|
||||
deprecationMessage = null;
|
||||
message = "The field DEPRECATION_MESSAGE does not exist, is not a valid string. This field is optional: use nil to indicate that the plugin is not deprecated.";
|
||||
return false;
|
||||
}
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message, when the UI text content could not be read.</param>
|
||||
/// <param name="pluginContent">The read UI text content.</param>
|
||||
/// <returns>True, when the UI text content could be read successfully.</returns>
|
||||
protected bool TryInitUITextContent(out string message, out Dictionary<string, string> pluginContent)
|
||||
{
|
||||
if (!this.state.Environment["UI_TEXT_CONTENT"].TryRead<LuaTable>(out var textTable))
|
||||
{
|
||||
message = "The UI_TEXT_CONTENT table does not exist or is not a valid table.";
|
||||
pluginContent = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ReadTextTable("root", textTable, out pluginContent);
|
||||
|
||||
message = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a flat or hierarchical text table.
|
||||
/// </summary>
|
||||
/// <param name="parent">The parent key(s).</param>
|
||||
/// <param name="table">The table to read.</param>
|
||||
/// <param name="tableContent">The read table content.</param>
|
||||
protected void ReadTextTable(string parent, LuaTable table, out Dictionary<string, string> tableContent)
|
||||
{
|
||||
tableContent = [];
|
||||
var lastKey = LuaValue.Nil;
|
||||
while (table.TryGetNext(lastKey, out var pair))
|
||||
{
|
||||
var keyText = pair.Key.ToString();
|
||||
if (pair.Value.TryRead<string>(out var value))
|
||||
tableContent[$"{parent}::{keyText}"] = value;
|
||||
|
||||
else if (pair.Value.TryRead<LuaTable>(out var t))
|
||||
{
|
||||
this.ReadTextTable($"{parent}::{keyText}", t, out var subContent);
|
||||
foreach (var (k, v) in subContent)
|
||||
tableContent[k] = v;
|
||||
}
|
||||
|
||||
lastKey = pair.Key;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
33
app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs
Normal file
33
app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs
Normal file
@ -0,0 +1,33 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public enum PluginCategory
|
||||
{
|
||||
NONE,
|
||||
CORE,
|
||||
|
||||
BUSINESS,
|
||||
INDUSTRY,
|
||||
UTILITY,
|
||||
SOFTWARE_DEVELOPMENT,
|
||||
GAMING,
|
||||
EDUCATION,
|
||||
ENTERTAINMENT,
|
||||
SOCIAL,
|
||||
SHOPPING,
|
||||
TRAVEL,
|
||||
HEALTH,
|
||||
FITNESS,
|
||||
FOOD,
|
||||
PARTY,
|
||||
SPORTS,
|
||||
NEWS,
|
||||
WEATHER,
|
||||
MUSIC,
|
||||
POLITICAL,
|
||||
SCIENCE,
|
||||
TECHNOLOGY,
|
||||
ART,
|
||||
FICTION,
|
||||
WRITING,
|
||||
CONTENT_CREATION,
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class PluginCategoryExtensions
|
||||
{
|
||||
public static string GetName(this PluginCategory type) => type switch
|
||||
{
|
||||
PluginCategory.NONE => "None",
|
||||
PluginCategory.CORE => "AI Studio Core",
|
||||
|
||||
PluginCategory.BUSINESS => "Business",
|
||||
PluginCategory.INDUSTRY => "Industry",
|
||||
PluginCategory.UTILITY => "Utility",
|
||||
PluginCategory.SOFTWARE_DEVELOPMENT => "Software Development",
|
||||
PluginCategory.GAMING => "Gaming",
|
||||
PluginCategory.EDUCATION => "Education",
|
||||
PluginCategory.ENTERTAINMENT => "Entertainment",
|
||||
PluginCategory.SOCIAL => "Social",
|
||||
PluginCategory.SHOPPING => "Shopping",
|
||||
PluginCategory.TRAVEL => "Travel",
|
||||
PluginCategory.HEALTH => "Health",
|
||||
PluginCategory.FITNESS => "Fitness",
|
||||
PluginCategory.FOOD => "Food",
|
||||
PluginCategory.PARTY => "Party",
|
||||
PluginCategory.SPORTS => "Sports",
|
||||
PluginCategory.NEWS => "News",
|
||||
PluginCategory.WEATHER => "Weather",
|
||||
PluginCategory.MUSIC => "Music",
|
||||
PluginCategory.POLITICAL => "Political",
|
||||
PluginCategory.SCIENCE => "Science",
|
||||
PluginCategory.TECHNOLOGY => "Technology",
|
||||
PluginCategory.ART => "Art",
|
||||
PluginCategory.FICTION => "Fiction",
|
||||
PluginCategory.WRITING => "Writing",
|
||||
PluginCategory.CONTENT_CREATION => "Content Creation",
|
||||
|
||||
_ => "Unknown plugin category",
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a plugin check.
|
||||
/// </summary>
|
||||
/// <param name="IsForbidden">In case the plugin is forbidden, this is true.</param>
|
||||
/// <param name="Message">The message that describes why the plugin is forbidden.</param>
|
||||
public readonly record struct PluginCheckResult(bool IsForbidden, string? Message);
|
125
app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
Normal file
125
app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
Normal file
@ -0,0 +1,125 @@
|
||||
using System.Reflection;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
using Lua;
|
||||
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class PluginFactory
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger("PluginFactory");
|
||||
private static readonly string DATA_DIR = SettingsManager.DataDirectory!;
|
||||
|
||||
public static async Task EnsureInternalPlugins()
|
||||
{
|
||||
LOG.LogInformation("Start ensuring internal plugins.");
|
||||
foreach (var plugin in Enum.GetValues<InternalPlugin>())
|
||||
{
|
||||
LOG.LogInformation($"Ensure plugin: {plugin}");
|
||||
await EnsurePlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsurePlugin(InternalPlugin plugin)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if DEBUG
|
||||
var basePath = Path.Join(Environment.CurrentDirectory, "Plugins");
|
||||
var resourceFileProvider = new PhysicalFileProvider(basePath);
|
||||
#else
|
||||
var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Plugins");
|
||||
#endif
|
||||
|
||||
var metaData = plugin.MetaData();
|
||||
var mainResourcePath = $"{metaData.ResourcePath}/plugin.lua";
|
||||
var resourceInfo = resourceFileProvider.GetFileInfo(mainResourcePath);
|
||||
|
||||
if(!resourceInfo.Exists)
|
||||
{
|
||||
LOG.LogError($"The plugin {plugin} does not exist. This should not happen, since the plugin is an integral part of AI Studio.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure that the additional resources exist:
|
||||
foreach (var content in resourceFileProvider.GetDirectoryContents(metaData.ResourcePath))
|
||||
{
|
||||
if(content.IsDirectory)
|
||||
{
|
||||
LOG.LogError("The plugin contains a directory. This is not allowed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await CopyPluginFile(content, metaData);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
LOG.LogError($"Was not able to ensure the plugin: {plugin}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CopyPluginFile(IFileInfo resourceInfo, InternalPluginData metaData)
|
||||
{
|
||||
await using var inputStream = resourceInfo.CreateReadStream();
|
||||
|
||||
var pluginsRoot = Path.Join(DATA_DIR, "plugins");
|
||||
var pluginTypeBasePath = Path.Join(pluginsRoot, metaData.Type.GetDirectory());
|
||||
|
||||
if (!Directory.Exists(pluginsRoot))
|
||||
Directory.CreateDirectory(pluginsRoot);
|
||||
|
||||
if (!Directory.Exists(pluginTypeBasePath))
|
||||
Directory.CreateDirectory(pluginTypeBasePath);
|
||||
|
||||
var pluginPath = Path.Join(pluginTypeBasePath, metaData.ResourceName);
|
||||
if (!Directory.Exists(pluginPath))
|
||||
Directory.CreateDirectory(pluginPath);
|
||||
|
||||
var pluginFilePath = Path.Join(pluginPath, resourceInfo.Name);
|
||||
|
||||
await using var outputStream = File.Create(pluginFilePath);
|
||||
await inputStream.CopyToAsync(outputStream);
|
||||
}
|
||||
|
||||
public static async Task LoadAll()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static async Task<PluginBase> Load(string path, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState)
|
||||
return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}");
|
||||
|
||||
var state = LuaState.Create();
|
||||
|
||||
try
|
||||
{
|
||||
await state.DoStringAsync(code, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (LuaParseException e)
|
||||
{
|
||||
return new NoPlugin($"Was not able to parse the plugin: {e.Message}");
|
||||
}
|
||||
|
||||
if (!state.Environment["TYPE"].TryRead<string>(out var typeText))
|
||||
return new NoPlugin("TYPE does not exist or is not a valid string.");
|
||||
|
||||
if (!Enum.TryParse<PluginType>(typeText, out var type))
|
||||
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
|
||||
|
||||
if(type is PluginType.NONE)
|
||||
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
|
||||
|
||||
return type switch
|
||||
{
|
||||
PluginType.LANGUAGE => new PluginLanguage(path, state, type),
|
||||
|
||||
_ => new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.")
|
||||
};
|
||||
}
|
||||
}
|
48
app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs
Normal file
48
app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed class PluginLanguage : PluginBase, ILanguagePlugin
|
||||
{
|
||||
private readonly Dictionary<string, string> content = [];
|
||||
|
||||
private ILanguagePlugin? baseLanguage;
|
||||
|
||||
public PluginLanguage(string path, LuaState state, PluginType type) : base(path, state, type)
|
||||
{
|
||||
if (this.TryInitUITextContent(out var issue, out var readContent))
|
||||
this.content = readContent;
|
||||
else
|
||||
this.pluginIssues.Add(issue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the base language plugin. This plugin will be used to fill in missing keys.
|
||||
/// </summary>
|
||||
/// <param name="baseLanguagePlugin">The base language plugin to use.</param>
|
||||
public void SetBaseLanguage(ILanguagePlugin baseLanguagePlugin) => this.baseLanguage = baseLanguagePlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a text from the language plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the key neither in the base language nor in this language exist,
|
||||
/// the value will be an empty string. Please note that the key is case-sensitive.
|
||||
/// Furthermore, the keys are in the format "root::key". That means that
|
||||
/// the keys are hierarchical and separated by "::".
|
||||
/// </remarks>
|
||||
/// <param name="key">The key to use to get the text.</param>
|
||||
/// <param name="value">The desired text.</param>
|
||||
/// <returns>True if the key exists, false otherwise.</returns>
|
||||
public bool TryGetText(string key, out string value)
|
||||
{
|
||||
if (this.content.TryGetValue(key, out value!))
|
||||
return true;
|
||||
|
||||
if(this.baseLanguage is not null && this.baseLanguage.TryGetText(key, out value))
|
||||
return true;
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
48
app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs
Normal file
48
app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
using Lua;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Loads Lua modules from a plugin directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Any plugin can load Lua modules from its own directory. This class is used to load these modules.
|
||||
/// Loading other modules outside the plugin directory is not allowed.
|
||||
/// </remarks>
|
||||
/// <param name="pluginDirectory">The directory where the plugin is located.</param>
|
||||
public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
|
||||
{
|
||||
private static readonly string PLUGIN_BASE_PATH = Path.Join(SettingsManager.DataDirectory, "plugins");
|
||||
|
||||
#region Implementation of ILuaModuleLoader
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Exists(string moduleName)
|
||||
{
|
||||
// Ensure that the user doesn't try to escape the plugin directory:
|
||||
if (moduleName.Contains("..") || pluginDirectory.Contains(".."))
|
||||
return false;
|
||||
|
||||
// Ensure that the plugin directory is nested in the plugin base path:
|
||||
if (!pluginDirectory.StartsWith(PLUGIN_BASE_PATH, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var path = Path.Join(pluginDirectory, $"{moduleName}.lua");
|
||||
return File.Exists(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<LuaModule> LoadAsync(string moduleName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Join(pluginDirectory, $"{moduleName}.lua");
|
||||
var code = await File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken);
|
||||
|
||||
return new(moduleName, code);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public enum PluginTargetGroup
|
||||
{
|
||||
NONE,
|
||||
|
||||
EVERYONE,
|
||||
CHILDREN,
|
||||
TEENAGERS,
|
||||
STUDENTS,
|
||||
ADULTS,
|
||||
|
||||
INDUSTRIAL_WORKERS,
|
||||
OFFICE_WORKERS,
|
||||
BUSINESS_PROFESSIONALS,
|
||||
SOFTWARE_DEVELOPERS,
|
||||
SCIENTISTS,
|
||||
TEACHERS,
|
||||
ARTISTS,
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class PluginTargetGroupExtensions
|
||||
{
|
||||
public static string Name(this PluginTargetGroup group) => group switch
|
||||
{
|
||||
PluginTargetGroup.NONE => "No target group",
|
||||
|
||||
PluginTargetGroup.EVERYONE => "Everyone",
|
||||
PluginTargetGroup.CHILDREN => "Children",
|
||||
PluginTargetGroup.TEENAGERS => "Teenagers",
|
||||
PluginTargetGroup.STUDENTS => "Students",
|
||||
PluginTargetGroup.ADULTS => "Adults",
|
||||
|
||||
PluginTargetGroup.INDUSTRIAL_WORKERS => "Industrial workers",
|
||||
PluginTargetGroup.OFFICE_WORKERS => "Office workers",
|
||||
PluginTargetGroup.BUSINESS_PROFESSIONALS => "Business professionals",
|
||||
PluginTargetGroup.SOFTWARE_DEVELOPERS => "Software developers",
|
||||
PluginTargetGroup.SCIENTISTS => "Scientists",
|
||||
PluginTargetGroup.TEACHERS => "Teachers",
|
||||
PluginTargetGroup.ARTISTS => "Artists",
|
||||
|
||||
_ => "Unknown target group",
|
||||
};
|
||||
}
|
11
app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs
Normal file
11
app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public enum PluginType
|
||||
{
|
||||
NONE,
|
||||
|
||||
LANGUAGE,
|
||||
ASSISTANT,
|
||||
CONFIGURATION,
|
||||
THEME,
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static class PluginTypeExtensions
|
||||
{
|
||||
public static string GetName(this PluginType type) => type switch
|
||||
{
|
||||
PluginType.LANGUAGE => "Language plugin",
|
||||
PluginType.ASSISTANT => "Assistant plugin",
|
||||
PluginType.CONFIGURATION => "Configuration plugin",
|
||||
PluginType.THEME => "Theme plugin",
|
||||
|
||||
_ => "Unknown plugin type",
|
||||
};
|
||||
|
||||
public static string GetDirectory(this PluginType type) => type switch
|
||||
{
|
||||
PluginType.LANGUAGE => "languages",
|
||||
PluginType.ASSISTANT => "assistants",
|
||||
PluginType.CONFIGURATION => "configurations",
|
||||
PluginType.THEME => "themes",
|
||||
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
90
app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs
Normal file
90
app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs
Normal file
@ -0,0 +1,90 @@
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a version number for a plugin.
|
||||
/// </summary>
|
||||
/// <param name="Major">The major version number.</param>
|
||||
/// <param name="Minor">The minor version number.</param>
|
||||
/// <param name="Patch">The patch version number.</param>
|
||||
public readonly record struct PluginVersion(int Major, int Minor, int Patch) : IComparable<PluginVersion>
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents no version number.
|
||||
/// </summary>
|
||||
public static readonly PluginVersion NONE = new(0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse the input string as a plugin version number.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to parse.</param>
|
||||
/// <param name="version">The parsed version number.</param>
|
||||
/// <returns>True when the input string was successfully parsed; otherwise, false.</returns>
|
||||
public static bool TryParse(string input, out PluginVersion version)
|
||||
{
|
||||
try
|
||||
{
|
||||
version = Parse(input);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
version = NONE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the input string as a plugin version number.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to parse.</param>
|
||||
/// <returns>The parsed version number.</returns>
|
||||
/// <exception cref="FormatException">The input string is not in the correct format.</exception>
|
||||
public static PluginVersion Parse(string input)
|
||||
{
|
||||
var segments = input.Split('.');
|
||||
if (segments.Length != 3)
|
||||
throw new FormatException("The input string must be in the format 'major.minor.patch'.");
|
||||
|
||||
var major = int.Parse(segments[0]);
|
||||
var minor = int.Parse(segments[1]);
|
||||
var patch = int.Parse(segments[2]);
|
||||
|
||||
if(major < 0 || minor < 0 || patch < 0)
|
||||
throw new FormatException("The major, minor, and patch numbers must be greater than or equal to 0.");
|
||||
|
||||
return new PluginVersion(major, minor, patch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the plugin version number to a string in the format 'major.minor.patch'.
|
||||
/// </summary>
|
||||
/// <returns>The plugin version number as a string.</returns>
|
||||
public override string ToString() => $"{this.Major}.{this.Minor}.{this.Patch}";
|
||||
|
||||
/// <summary>
|
||||
/// Compares the plugin version number to another plugin version number.
|
||||
/// </summary>
|
||||
/// <param name="other">The other plugin version number to compare to.</param>
|
||||
/// <returns>A value indicating the relative order of the plugin version numbers.</returns>
|
||||
public int CompareTo(PluginVersion other)
|
||||
{
|
||||
var majorCompare = this.Major.CompareTo(other.Major);
|
||||
if (majorCompare != 0)
|
||||
return majorCompare;
|
||||
|
||||
var minorCompare = this.Minor.CompareTo(other.Minor);
|
||||
if (minorCompare != 0)
|
||||
return minorCompare;
|
||||
|
||||
return this.Patch.CompareTo(other.Patch);
|
||||
}
|
||||
|
||||
public static bool operator >(PluginVersion left, PluginVersion right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator <(PluginVersion left, PluginVersion right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator >=(PluginVersion left, PluginVersion right) => left.CompareTo(right) >= 0;
|
||||
|
||||
public static bool operator <=(PluginVersion left, PluginVersion right) => left.CompareTo(right) <= 0;
|
||||
}
|
@ -22,6 +22,12 @@
|
||||
"resolved": "1.12.0",
|
||||
"contentHash": "VHtVZmfoYhQyA/POvZRLuTpCz1zhzIDrdYRJIRV73e9wKAzjW71biYNOHOWx8MxEX3TE4TWVfx1QDRoZcj2AWw=="
|
||||
},
|
||||
"LuaCSharp": {
|
||||
"type": "Direct",
|
||||
"requested": "[0.4.2, )",
|
||||
"resolved": "0.4.2",
|
||||
"contentHash": "wS0hp7EFx+llJ/U/7Ykz4FSmQf8DH4mNejwo5/h1KuFyguzGZbKhTO22X54pXnuqa5cIKfEfQ29dluHHnCX05Q=="
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Embedded": {
|
||||
"type": "Direct",
|
||||
"requested": "[9.0.3, )",
|
||||
|
3
app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md
Normal file
3
app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md
Normal file
@ -0,0 +1,3 @@
|
||||
# v0.9.39, build 214 (2025-03-xx xx:xx UTC)
|
||||
- Added a feature flag for the plugin system. This flag is disabled by default and can be enabled inside the app settings. Please note that this feature is still in development; there are no plugins available yet.
|
||||
- Added the Lua library we use for the plugin system to the about page.
|
@ -46,6 +46,9 @@ public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer
|
||||
if (IsInConstContext(stringLiteral))
|
||||
return;
|
||||
|
||||
if (IsInParameterDefaultValue(stringLiteral))
|
||||
return;
|
||||
|
||||
var diagnostic = Diagnostic.Create(RULE, stringLiteral.GetLocation());
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
@ -65,4 +68,21 @@ public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsInParameterDefaultValue(LiteralExpressionSyntax stringLiteral)
|
||||
{
|
||||
// Prüfen, ob das String-Literal Teil eines Parameter-Defaults ist
|
||||
var parameter = stringLiteral.FirstAncestorOrSelf<ParameterSyntax>();
|
||||
if (parameter is null)
|
||||
return false;
|
||||
|
||||
// Überprüfen, ob das String-Literal im Default-Wert des Parameters verwendet wird
|
||||
if (parameter.Default is not null &&
|
||||
parameter.Default.Value == stringLiteral)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user