From ee2a73ccd848e9b0efa7dcf3eb92269a5122a546 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 22 Mar 2025 21:12:14 +0100 Subject: [PATCH] Added plugin architecture (#322) --- app/MindWork AI Studio/Chat/Workspace.cs | 12 - .../Layout/MainLayout.razor.cs | 4 + .../MindWork AI Studio.csproj | 3 +- app/MindWork AI Studio/Pages/About.razor | 1 + .../contentHome.lua | 3 + .../plugin.lua | 39 ++ .../contentHome.lua | 3 + .../plugin.lua | 41 ++ app/MindWork AI Studio/Program.cs | 4 + .../Settings/DataModel/PreviewFeatures.cs | 2 + .../DataModel/PreviewFeaturesExtensions.cs | 1 + .../DataModel/PreviewVisibilityExtensions.cs | 1 + app/MindWork AI Studio/Tools/CommonTools.cs | 22 + .../Tools/PluginSystem/ForbiddenPlugins.cs | 99 ++++ .../Tools/PluginSystem/ILanguagePlugin.cs | 21 + .../Tools/PluginSystem/InternalPlugin.cs | 7 + .../Tools/PluginSystem/InternalPluginData.cs | 8 + .../PluginSystem/InternalPluginExtensions.cs | 12 + .../Tools/PluginSystem/NoModuleLoader.cs | 20 + .../Tools/PluginSystem/NoPlugin.cs | 9 + .../Tools/PluginSystem/PluginBase.cs | 521 ++++++++++++++++++ .../Tools/PluginSystem/PluginCategory.cs | 33 ++ .../PluginSystem/PluginCategoryExtensions.cs | 38 ++ .../Tools/PluginSystem/PluginCheckResult.cs | 8 + .../Tools/PluginSystem/PluginFactory.cs | 125 +++++ .../Tools/PluginSystem/PluginLanguage.cs | 48 ++ .../Tools/PluginSystem/PluginLoader.cs | 48 ++ .../Tools/PluginSystem/PluginTargetGroup.cs | 20 + .../PluginTargetGroupExtensions.cs | 25 + .../Tools/PluginSystem/PluginType.cs | 11 + .../PluginSystem/PluginTypeExtensions.cs | 24 + .../Tools/PluginSystem/PluginVersion.cs | 90 +++ app/MindWork AI Studio/packages.lock.json | 6 + .../wwwroot/changelog/v0.9.39.md | 3 + .../UsageAnalyzers/EmptyStringAnalyzer.cs | 20 + 35 files changed, 1319 insertions(+), 13 deletions(-) delete mode 100644 app/MindWork AI Studio/Chat/Workspace.cs create mode 100644 app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/contentHome.lua create mode 100644 app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua create mode 100644 app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/contentHome.lua create mode 100644 app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua create mode 100644 app/MindWork AI Studio/Tools/CommonTools.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/ForbiddenPlugins.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/InternalPlugin.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/InternalPluginData.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/InternalPluginExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginCategoryExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginCheckResult.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroup.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroupExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs create mode 100644 app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md diff --git a/app/MindWork AI Studio/Chat/Workspace.cs b/app/MindWork AI Studio/Chat/Workspace.cs deleted file mode 100644 index 0dad2b9b..00000000 --- a/app/MindWork AI Studio/Chat/Workspace.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AIStudio.Chat; - -/// -/// Data about a workspace. -/// -/// The name of the workspace. -public sealed class Workspace(string name) -{ - public string Name { get; set; } = name; - - public List Threads { get; set; } = new(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index b302b23e..32d9e981 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -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 ]); diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index bdfe8287..90069618 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -41,8 +41,8 @@ - + @@ -52,6 +52,7 @@ + diff --git a/app/MindWork AI Studio/Pages/About.razor b/app/MindWork AI Studio/Pages/About.razor index 74492c4a..282e5e72 100644 --- a/app/MindWork AI Studio/Pages/About.razor +++ b/app/MindWork AI Studio/Pages/About.razor @@ -108,6 +108,7 @@ + diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/contentHome.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/contentHome.lua new file mode 100644 index 00000000..f26d9aff --- /dev/null +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/contentHome.lua @@ -0,0 +1,3 @@ +CONTENT_HOME = { + LetsGetStarted = "Lass uns anfangen", +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua new file mode 100644 index 00000000..4801b782 --- /dev/null +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -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, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/contentHome.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/contentHome.lua new file mode 100644 index 00000000..d1805fd0 --- /dev/null +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/contentHome.lua @@ -0,0 +1,3 @@ +CONTENT_HOME = { + LetsGetStarted = "Let's get started", +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua new file mode 100644 index 00000000..6e2fea50 --- /dev/null +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -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, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 61261b16..cc37d9cd 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -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(); + // Get a program logger: var programLogger = app.Services.GetRequiredService>(); programLogger.LogInformation("Starting the AI Studio server."); diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs index 825e8037..ff642a0a 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs @@ -8,4 +8,6 @@ public enum PreviewFeatures // PRE_WRITER_MODE_2024, PRE_RAG_2024, + + PRE_PLUGINS_2025, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs index 133d6538..2eb25587 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs @@ -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" }; diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs index f80939f6..b0f07716 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs @@ -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; diff --git a/app/MindWork AI Studio/Tools/CommonTools.cs b/app/MindWork AI Studio/Tools/CommonTools.cs new file mode 100644 index 00000000..26150880 --- /dev/null +++ b/app/MindWork AI Studio/Tools/CommonTools.cs @@ -0,0 +1,22 @@ +using System.Text; + +namespace AIStudio.Tools; + +public static class CommonTools +{ + /// + /// Get all the values (the names) of an enum as a string, separated by commas. + /// + /// The enum type to get the values of. + /// The values to exclude from the result. + /// The values of the enum as a string, separated by commas. + public static string GetAllEnumValues(params TEnum[] exceptions) where TEnum : struct, Enum + { + var sb = new StringBuilder(); + foreach (var value in Enum.GetValues()) + if(!exceptions.Contains(value)) + sb.Append(value).Append(", "); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ForbiddenPlugins.cs b/app/MindWork AI Studio/Tools/PluginSystem/ForbiddenPlugins.cs new file mode 100644 index 00000000..b38459d6 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ForbiddenPlugins.cs @@ -0,0 +1,99 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Checks if a plugin is forbidden. +/// +public static class ForbiddenPlugins +{ + private const string ID_PATTERN = "ID = \""; + private static readonly int ID_PATTERN_LEN = ID_PATTERN.Length; + + /// + /// Checks if the given code represents a forbidden plugin. + /// + /// The code to check. + /// The result of the check. + public static PluginCheckResult Check(ReadOnlySpan code) + { + var endIndex = 0; + var foundAnyId = false; + var id = ReadOnlySpan.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 code, out ReadOnlySpan 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.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; + } + + /// + /// The forbidden plugins. + /// + /// + /// A dictionary that maps the GUID of a plugin to the reason why it is forbidden. + /// + // ReSharper disable once CollectionNeverUpdated.Local + private static readonly Dictionary FORBIDDEN_PLUGINS = + [ + ]; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs new file mode 100644 index 00000000..a33bf3f5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs @@ -0,0 +1,21 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a contract for a language plugin. +/// +public interface ILanguagePlugin +{ + /// + /// Tries to get a text from the language plugin. + /// + /// + /// 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 "::". + /// + /// The key to use to get the text. + /// The desired text. + /// True if the key exists, false otherwise. + public bool TryGetText(string key, out string value); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/InternalPlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/InternalPlugin.cs new file mode 100644 index 00000000..1cbd8ca7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/InternalPlugin.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Tools.PluginSystem; + +public enum InternalPlugin +{ + LANGUAGE_EN_US, + LANGUAGE_DE_DE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginData.cs b/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginData.cs new file mode 100644 index 00000000..d7595f2a --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginData.cs @@ -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}"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginExtensions.cs new file mode 100644 index 00000000..3a25aa18 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/InternalPluginExtensions.cs @@ -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") + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs b/app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs new file mode 100644 index 00000000..d40d2237 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/NoModuleLoader.cs @@ -0,0 +1,20 @@ +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// This Lua module loader does not load any modules. +/// +public sealed class NoModuleLoader : ILuaModuleLoader +{ + #region Implementation of ILuaModuleLoader + + public bool Exists(string moduleName) => false; + + public ValueTask LoadAsync(string moduleName, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(new LuaModule()); + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs new file mode 100644 index 00000000..a21ab819 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/NoPlugin.cs @@ -0,0 +1,9 @@ +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a plugin that could not be loaded. +/// +/// The error message that occurred while parsing the plugin. +public sealed class NoPlugin(string parsingError) : PluginBase(string.Empty, LuaState.Create(), PluginType.NONE, parsingError); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs new file mode 100644 index 00000000..4f719d18 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs @@ -0,0 +1,521 @@ +using Lua; +using Lua.Standard; + +// ReSharper disable MemberCanBePrivate.Global +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents the base of any AI Studio plugin. +/// +public abstract class PluginBase +{ + private readonly IReadOnlyCollection baseIssues; + protected readonly LuaState state; + + protected readonly List pluginIssues = []; + + /// + /// The type of this plugin. + /// + public PluginType Type { get; } + + /// + /// The ID of this plugin. + /// + public Guid Id { get; } + + /// + /// The name of this plugin. + /// + public string Name { get; } = string.Empty; + + /// + /// The description of this plugin. + /// + public string Description { get; } = string.Empty; + + /// + /// The version of this plugin. + /// + public PluginVersion Version { get; } + + /// + /// The authors of this plugin. + /// + public string[] Authors { get; } = []; + + /// + /// The support contact for this plugin. + /// + public string SupportContact { get; } = string.Empty; + + /// + /// The source URL of this plugin. + /// + public string SourceURL { get; } = string.Empty; + + /// + /// The categories of this plugin. + /// + public PluginCategory[] Categories { get; } = []; + + /// + /// The target groups of this plugin. + /// + public PluginTargetGroup[] TargetGroups { get; } = []; + + /// + /// True, when the plugin is maintained. + /// + public bool IsMaintained { get; } + + /// + /// The message that should be displayed when the plugin is deprecated. + /// + public string? DeprecationMessage { get; } + + /// + /// The issues that occurred during the initialization of this plugin. + /// + public IEnumerable Issues => this.baseIssues.Concat(this.pluginIssues); + + /// + /// True, when the plugin is valid. + /// + /// + /// False means that there were issues during the initialization of the plugin. + /// Please check the Issues property for more information. + /// + 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(); + 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 + + /// + /// Tries to read the ID of the plugin. + /// + /// The error message, when the ID could not be read. + /// The read ID. + /// True, when the ID could be read successfully. + private bool TryInitId(out string message, out Guid id) + { + if (!this.state.Environment["ID"].TryRead(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; + } + + /// + /// Tries to read the name of the plugin. + /// + /// The error message, when the name could not be read. + /// The read name. + /// True, when the name could be read successfully. + 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; + } + + /// + /// Tries to read the description of the plugin. + /// + /// The error message, when the description could not be read. + /// The read description. + /// True, when the description could be read successfully. + 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; + } + + /// + /// Tries to read the version of the plugin. + /// + /// The error message, when the version could not be read. + /// The read version. + /// True, when the version could be read successfully. + private bool TryInitVersion(out string message, out PluginVersion version) + { + if (!this.state.Environment["VERSION"].TryRead(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; + } + + /// + /// Tries to read the authors of the plugin. + /// + /// The error message, when the authors could not be read. + /// The read authors. + /// True, when the authors could be read successfully. + private bool TryInitAuthors(out string message, out string[] authors) + { + if (!this.state.Environment["AUTHORS"].TryRead(out var authorsTable)) + { + authors = []; + message = "The table AUTHORS does not exist or is using an invalid syntax."; + return false; + } + + var authorList = new List(); + foreach(var author in authorsTable.GetArraySpan()) + if(author.TryRead(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; + } + + /// + /// Tries to read the support contact for the plugin. + /// + /// The error message, when the support contact could not be read. + /// The read support contact. + /// True, when the support contact could be read successfully. + 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; + } + + /// + /// Try to read the source URL of the plugin. + /// + /// The error message, when the source URL could not be read. + /// The read source URL. + /// True, when the source URL could be read successfully. + 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; + } + + /// + /// Tries to read the categories of the plugin. + /// + /// The error message, when the categories could not be read. + /// The read categories. + /// True, when the categories could be read successfully. + private bool TryInitCategories(out string message, out PluginCategory[] categories) + { + if (!this.state.Environment["CATEGORIES"].TryRead(out var categoriesTable)) + { + categories = []; + message = "The table CATEGORIES does not exist or is using an invalid syntax."; + return false; + } + + var categoryList = new List(); + foreach(var luaCategory in categoriesTable.GetArraySpan()) + if(luaCategory.TryRead(out var categoryName)) + if(Enum.TryParse(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; + } + + /// + /// Tries to read the intended target groups for the plugin. + /// + /// The error message, when the target groups could not be read. + /// The read target groups. + /// True, when the target groups could be read successfully. + private bool TryInitTargetGroups(out string message, out PluginTargetGroup[] targetGroups) + { + if (!this.state.Environment["TARGET_GROUPS"].TryRead(out var targetGroupsTable)) + { + targetGroups = []; + message = "The table TARGET_GROUPS does not exist or is using an invalid syntax."; + return false; + } + + var targetGroupList = new List(); + foreach(var luaTargetGroup in targetGroupsTable.GetArraySpan()) + if(luaTargetGroup.TryRead(out var targetGroupName)) + if(Enum.TryParse(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; + } + + /// + /// Tries to read the maintenance status of the plugin. + /// + /// The error message, when the maintenance status could not be read. + /// The read maintenance status. + /// True, when the maintenance status could be read successfully. + 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; + } + + /// + /// Tries to read the deprecation message of the plugin. + /// + /// The error message, when the deprecation message could not be read. + /// The read deprecation message. + /// True, when the deprecation message could be read successfully. + 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; + } + + /// + /// Tries to initialize the UI text content of the plugin. + /// + /// The error message, when the UI text content could not be read. + /// The read UI text content. + /// True, when the UI text content could be read successfully. + protected bool TryInitUITextContent(out string message, out Dictionary pluginContent) + { + if (!this.state.Environment["UI_TEXT_CONTENT"].TryRead(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; + } + + /// + /// Reads a flat or hierarchical text table. + /// + /// The parent key(s). + /// The table to read. + /// The read table content. + protected void ReadTextTable(string parent, LuaTable table, out Dictionary tableContent) + { + tableContent = []; + var lastKey = LuaValue.Nil; + while (table.TryGetNext(lastKey, out var pair)) + { + var keyText = pair.Key.ToString(); + if (pair.Value.TryRead(out var value)) + tableContent[$"{parent}::{keyText}"] = value; + + else if (pair.Value.TryRead(out var t)) + { + this.ReadTextTable($"{parent}::{keyText}", t, out var subContent); + foreach (var (k, v) in subContent) + tableContent[k] = v; + } + + lastKey = pair.Key; + } + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs new file mode 100644 index 00000000..00afcd0e --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginCategory.cs @@ -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, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginCategoryExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginCategoryExtensions.cs new file mode 100644 index 00000000..35303c06 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginCategoryExtensions.cs @@ -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", + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginCheckResult.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginCheckResult.cs new file mode 100644 index 00000000..f390a47d --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginCheckResult.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents the result of a plugin check. +/// +/// In case the plugin is forbidden, this is true. +/// The message that describes why the plugin is forbidden. +public readonly record struct PluginCheckResult(bool IsForbidden, string? Message); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs new file mode 100644 index 00000000..e20d80f4 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -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()) + { + 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 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(out var typeText)) + return new NoPlugin("TYPE does not exist or is not a valid string."); + + if (!Enum.TryParse(typeText, out var type)) + return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues()}"); + + if(type is PluginType.NONE) + return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues()}"); + + 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.") + }; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs new file mode 100644 index 00000000..e1676e49 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs @@ -0,0 +1,48 @@ +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +public sealed class PluginLanguage : PluginBase, ILanguagePlugin +{ + private readonly Dictionary 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); + } + + /// + /// Sets the base language plugin. This plugin will be used to fill in missing keys. + /// + /// The base language plugin to use. + public void SetBaseLanguage(ILanguagePlugin baseLanguagePlugin) => this.baseLanguage = baseLanguagePlugin; + + /// + /// Tries to get a text from the language plugin. + /// + /// + /// 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 "::". + /// + /// The key to use to get the text. + /// The desired text. + /// True if the key exists, false otherwise. + 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; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs new file mode 100644 index 00000000..ec81f73c --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginLoader.cs @@ -0,0 +1,48 @@ +using System.Text; + +using AIStudio.Settings; + +using Lua; + +namespace AIStudio.Tools.PluginSystem; + +/// +/// Loads Lua modules from a plugin directory. +/// +/// +/// 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. +/// +/// The directory where the plugin is located. +public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader +{ + private static readonly string PLUGIN_BASE_PATH = Path.Join(SettingsManager.DataDirectory, "plugins"); + + #region Implementation of ILuaModuleLoader + + /// + 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); + } + + /// + public async ValueTask 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 +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroup.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroup.cs new file mode 100644 index 00000000..102aa857 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroup.cs @@ -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, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroupExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroupExtensions.cs new file mode 100644 index 00000000..7a14123f --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginTargetGroupExtensions.cs @@ -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", + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs new file mode 100644 index 00000000..5730e62f --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginType.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Tools.PluginSystem; + +public enum PluginType +{ + NONE, + + LANGUAGE, + ASSISTANT, + CONFIGURATION, + THEME, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs new file mode 100644 index 00000000..b65eb502 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginTypeExtensions.cs @@ -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", + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs new file mode 100644 index 00000000..d5507a56 --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginVersion.cs @@ -0,0 +1,90 @@ +// ReSharper disable MemberCanBePrivate.Global +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a version number for a plugin. +/// +/// The major version number. +/// The minor version number. +/// The patch version number. +public readonly record struct PluginVersion(int Major, int Minor, int Patch) : IComparable +{ + /// + /// Represents no version number. + /// + public static readonly PluginVersion NONE = new(0, 0, 0); + + /// + /// Tries to parse the input string as a plugin version number. + /// + /// The input string to parse. + /// The parsed version number. + /// True when the input string was successfully parsed; otherwise, false. + public static bool TryParse(string input, out PluginVersion version) + { + try + { + version = Parse(input); + return true; + } + catch + { + version = NONE; + return false; + } + } + + /// + /// Parses the input string as a plugin version number. + /// + /// The input string to parse. + /// The parsed version number. + /// The input string is not in the correct format. + 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); + } + + /// + /// Converts the plugin version number to a string in the format 'major.minor.patch'. + /// + /// The plugin version number as a string. + public override string ToString() => $"{this.Major}.{this.Minor}.{this.Patch}"; + + /// + /// Compares the plugin version number to another plugin version number. + /// + /// The other plugin version number to compare to. + /// A value indicating the relative order of the plugin version numbers. + 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; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index 37a21d8a..8020366c 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -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, )", diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md b/app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md new file mode 100644 index 00000000..4fb08d00 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.9.39.md @@ -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. \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs index f6cc65b7..5092d436 100644 --- a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs @@ -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(); + 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; + } } \ No newline at end of file