Configure language & start language plugins (#400)
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, deb) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2025-04-12 21:13:33 +02:00
1 parent 171ed37c27
commit 3fc15d9789
31 files changed
+612 -156

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ public enum Event
STATE_HAS_CHANGED,
CONFIGURATION_CHANGED,
COLOR_THEME_CHANGED,
STARTUP_PLUGIN_SYSTEM,
PLUGINS_RELOADED,
SHOW_ERROR,
+62
View File
@@ -0,0 +1,62 @@
// ReSharper disable MemberCanBePrivate.Global
namespace AIStudio.Tools;
/// <summary>
/// Implements the Fowler–Noll–Vo hash function for 32-bit and 64-bit hashes.
/// </summary>
public static class FNVHash
{
private const uint FNV_OFFSET_BASIS_32_BIT = 2_166_136_261;
private const ulong FNV_OFFSET_BASIS_64_BIT = 14_695_981_039_346_656_037;
private const uint FNV_PRIME_32_BIT = 16_777_619;
private const ulong FNV_PRIME_64_BIT = 1_099_511_628_211;
/// <summary>
/// Computes the 32bit FNV-1a hash of a string.
/// </summary>
/// <param name="text">The string to hash.</param>
/// <returns>The 32bit FNV-1a hash of the string.</returns>
public static uint ToFNV32(this string text) => ToFNV32(text.AsSpan());
/// <summary>
/// Computes the 32bit FNV-1a hash of a string.
/// </summary>
/// <param name="text">The string to hash.</param>
/// <returns>The 32bit FNV-1a hash of the string.</returns>
public static uint ToFNV32(this ReadOnlySpan<char> text)
{
var hash = FNV_OFFSET_BASIS_32_BIT;
foreach (var c in text)
{
hash ^= c;
hash *= FNV_PRIME_32_BIT;
}
return hash;
}
/// <summary>
/// Computes the 64bit FNV-1a hash of a string.
/// </summary>
/// <param name="text">The string to hash.</param>
/// <returns>The 64bit FNV-1a hash of the string.</returns>
public static ulong ToFNV64(this string text) => ToFNV64(text.AsSpan());
/// <summary>
/// Computes the 64bit FNV-1a hash of a string.
/// </summary>
/// <param name="text">The string to hash.</param>
/// <returns>The 64bit FNV-1a hash of the string.</returns>
public static ulong ToFNV64(this ReadOnlySpan<char> text)
{
var hash = FNV_OFFSET_BASIS_64_BIT;
foreach (var c in text)
{
hash ^= c;
hash *= FNV_PRIME_64_BIT;
}
return hash;
}
}
@@ -0,0 +1,6 @@
namespace AIStudio.Tools.PluginSystem;
public interface IAvailablePlugin : IPluginMetadata
{
public string LocalPath { get; }
}
@@ -0,0 +1,21 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Represents a contract to access text from a language plugin.
/// </summary>
public interface ILang
{
/// <summary>
/// Tries to get a text from the language plugin.
/// </summary>
/// <remarks>
/// The given fallback text is used to determine the key for
/// the language plugin. Base for the key is the namespace of
/// the using component and the fallback text in English (US).
/// The given text getting hashed. When the key does not exist,
/// the fallback text will be returned.
/// </remarks>
/// <param name="fallbackEN">The fallback text in English (US).</param>
/// <returns>The text from the language plugin or the fallback text.</returns>
public string T(string fallbackEN);
}
@@ -16,6 +16,17 @@ public interface ILanguagePlugin
/// </remarks>
/// <param name="key">The key to use to get the text.</param>
/// <param name="value">The desired text.</param>
/// <param name="logWarning">When true, a warning will be logged if the key does not exist.</param>
/// <returns>True if the key exists, false otherwise.</returns>
public bool TryGetText(string key, out string value);
public bool TryGetText(string key, out string value, bool logWarning = false);
/// <summary>
/// Gets the IETF tag of the language plugin.
/// </summary>
public string IETFTag { get; }
/// <summary>
/// Gets the name of the language.
/// </summary>
public string LangName { get; }
}
@@ -0,0 +1,26 @@
using Lua;
namespace AIStudio.Tools.PluginSystem;
public sealed class NoPluginLanguage : PluginBase, ILanguagePlugin
{
public static readonly NoPluginLanguage INSTANCE = new();
private NoPluginLanguage() : base(true, LuaState.Create(), PluginType.LANGUAGE, string.Empty)
{
}
#region Implementation of ILanguagePlugin
public bool TryGetText(string key, out string value, bool logWarning = false)
{
value = string.Empty;
return true;
}
public string IETFTag => string.Empty;
public string LangName => string.Empty;
#endregion
}
@@ -4,6 +4,12 @@ public static partial class PluginFactory
{
public static void SetUpHotReloading()
{
if (!IS_INITIALIZED)
{
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
return;
}
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
try
{
@@ -10,6 +10,12 @@ public static partial class PluginFactory
{
public static async Task EnsureInternalPlugins()
{
if (!IS_INITIALIZED)
{
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
return;
}
LOG.LogInformation("Start ensuring internal plugins.");
foreach (var plugin in Enum.GetValues<InternalPlugin>())
{
@@ -40,15 +46,15 @@ public static partial class PluginFactory
}
// Ensure that the additional resources exist:
foreach (var content in resourceFileProvider.GetDirectoryContents(metaData.ResourcePath))
foreach (var contentFilePath in resourceFileProvider.GetDirectoryContents(metaData.ResourcePath))
{
if(content.IsDirectory)
if(contentFilePath.IsDirectory)
{
LOG.LogError("The plugin contains a directory. This is not allowed.");
continue;
}
await CopyInternalPluginFile(content, metaData);
await CopyInternalPluginFile(contentFilePath, metaData);
}
}
catch
@@ -57,9 +63,9 @@ public static partial class PluginFactory
}
}
private static async Task CopyInternalPluginFile(IFileInfo resourceInfo, InternalPluginData metaData)
private static async Task CopyInternalPluginFile(IFileInfo resourceFilePath, InternalPluginData metaData)
{
await using var inputStream = resourceInfo.CreateReadStream();
await using var inputStream = resourceFilePath.CreateReadStream();
var pluginTypeBasePath = Path.Join(INTERNAL_PLUGINS_ROOT, metaData.Type.GetDirectory());
@@ -73,7 +79,7 @@ public static partial class PluginFactory
if (!Directory.Exists(pluginPath))
Directory.CreateDirectory(pluginPath);
var pluginFilePath = Path.Join(pluginPath, resourceInfo.Name);
var pluginFilePath = Path.Join(pluginPath, resourceFilePath.Name);
await using var outputStream = File.Create(pluginFilePath);
await inputStream.CopyToAsync(outputStream);
@@ -9,29 +9,45 @@ namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger("PluginFactory");
private static readonly string DATA_DIR = SettingsManager.DataDirectory!;
private static readonly string PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins");
private static readonly string INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly List<IAvailablePlugin> AVAILABLE_PLUGINS = [];
private static readonly List<PluginBase> RUNNING_PLUGINS = [];
private static readonly FileSystemWatcher HOT_RELOAD_WATCHER;
private static readonly List<IPluginMetadata> AVAILABLE_PLUGINS = [];
private static bool IS_INITIALIZED;
private static string DATA_DIR = string.Empty;
private static string PLUGINS_ROOT = string.Empty;
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
/// <summary>
/// A list of all available plugins.
/// </summary>
public static IReadOnlyCollection<IPluginMetadata> AvailablePlugins => AVAILABLE_PLUGINS;
/// <summary>
/// A list of all running plugins.
/// </summary>
public static IReadOnlyCollection<PluginBase> RunningPlugins => RUNNING_PLUGINS;
static PluginFactory()
public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN;
/// <summary>
/// Set up the plugin factory. We will read the data directory from the settings manager.
/// Afterward, we will create the plugins directory and the internal plugin directory.
/// </summary>
public static void Setup()
{
DATA_DIR = SettingsManager.DataDirectory!;
PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins");
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
if (!Directory.Exists(PLUGINS_ROOT))
Directory.CreateDirectory(PLUGINS_ROOT);
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
IS_INITIALIZED = true;
}
/// <summary>
@@ -48,6 +64,12 @@ public static partial class PluginFactory
/// </remarks>
public static async Task LoadAll(CancellationToken cancellationToken = default)
{
if (!IS_INITIALIZED)
{
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
return;
}
LOG.LogInformation("Start loading plugins.");
if (!Directory.Exists(PLUGINS_ROOT))
{
@@ -96,8 +118,11 @@ public static partial class PluginFactory
}
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin));
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath));
}
// Start or restart all plugins:
await RestartAllPlugins(cancellationToken);
}
private static async Task<PluginBase> Load(string pluginPath, string code, CancellationToken cancellationToken = default)
@@ -148,9 +173,100 @@ public static partial class PluginFactory
_ => new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.")
};
}
private static async Task RestartAllPlugins(CancellationToken cancellationToken = default)
{
LOG.LogInformation("Try to start or restart all plugins.");
RUNNING_PLUGINS.Clear();
//
// Get the base language plugin. This is the plugin that will be used to fill in missing keys.
//
var baseLanguagePluginId = InternalPlugin.LANGUAGE_EN_US.MetaData().Id;
var baseLanguagePluginMetaData = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == baseLanguagePluginId);
if (baseLanguagePluginMetaData is null)
{
LOG.LogError($"Was not able to find the base language plugin: Id='{baseLanguagePluginId}'. Please check your installation.");
return;
}
var startedBasePlugin = await Start(baseLanguagePluginMetaData, cancellationToken);
if (startedBasePlugin is NoPlugin noPlugin)
{
LOG.LogError($"Was not able to start the base language plugin: Id='{baseLanguagePluginId}'. Reason: {noPlugin.Issues.First()}");
return;
}
if (startedBasePlugin is PluginLanguage languagePlugin)
{
BASE_LANGUAGE_PLUGIN = languagePlugin;
RUNNING_PLUGINS.Add(languagePlugin);
LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'");
}
else
{
LOG.LogError($"Was not able to start the base language plugin: Id='{baseLanguagePluginId}'. Reason: {string.Join("; ", startedBasePlugin.Issues)}");
return;
}
//
// Iterate over all available plugins and try to start them.
//
foreach (var availablePlugin in AVAILABLE_PLUGINS)
{
if(cancellationToken.IsCancellationRequested)
break;
if (availablePlugin.Id == baseLanguagePluginId)
continue;
if (availablePlugin.IsInternal || SETTINGS_MANAGER.IsPluginEnabled(availablePlugin))
if(await Start(availablePlugin, cancellationToken) is { IsValid: true } plugin)
RUNNING_PLUGINS.Add(plugin);
// Inform all components that the plugins have been reloaded or started:
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
}
}
private static async Task<PluginBase> Start(IAvailablePlugin meta, CancellationToken cancellationToken = default)
{
var pluginMainFile = Path.Join(meta.LocalPath, "plugin.lua");
if(!File.Exists(pluginMainFile))
{
LOG.LogError($"Was not able to start plugin: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. Reason: The plugin file does not exist.");
return new NoPlugin($"The plugin file does not exist: {pluginMainFile}");
}
var code = await File.ReadAllTextAsync(pluginMainFile, Encoding.UTF8, cancellationToken);
var plugin = await Load(meta.LocalPath, code, cancellationToken);
if (plugin is NoPlugin noPlugin)
{
LOG.LogError($"Was not able to start plugin: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. Reason: {noPlugin.Issues.First()}");
return noPlugin;
}
if (plugin.IsValid)
{
//
// When this is a language plugin, we need to set the base language plugin.
//
if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE)
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
LOG.LogInformation($"Successfully started plugin: Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}'");
return plugin;
}
LOG.LogError($"Was not able to start plugin: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. Reasons: {string.Join("; ", plugin.Issues)}");
return new NoPlugin($"Was not able to start plugin: Id='{meta.Id}', Type='{meta.Type}', Name='{meta.Name}', Version='{meta.Version}'. Reasons: {string.Join("; ", plugin.Issues)}");
}
public static void Dispose()
{
if(!IS_INITIALIZED)
return;
HOT_RELOAD_WATCHER.Dispose();
}
}
@@ -4,9 +4,12 @@ namespace AIStudio.Tools.PluginSystem;
public sealed class PluginLanguage : PluginBase, ILanguagePlugin
{
private static readonly ILogger<PluginLanguage> LOGGER = Program.LOGGER_FACTORY.CreateLogger<PluginLanguage>();
private readonly Dictionary<string, string> content = [];
private readonly List<ILanguagePlugin> otherLanguagePlugins = [];
private readonly string langCultureTag;
private readonly string langName;
private ILanguagePlugin? baseLanguage;
@@ -15,6 +18,9 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
if(!this.TryInitIETFTag(out var issue, out this.langCultureTag))
this.pluginIssues.Add(issue);
if(!this.TryInitLangName(out issue, out this.langName))
this.pluginIssues.Add(issue);
if (this.TryInitUITextContent(out issue, out var readContent))
this.content = readContent;
else
@@ -36,39 +42,6 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
/// </remarks>
/// <param name="languagePlugin">The language plugin to add.</param>
public void AddOtherLanguagePlugin(ILanguagePlugin languagePlugin) => this.otherLanguagePlugins.Add(languagePlugin);
/// <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)
{
// First, we check if the key is part of the main language pack:
if (this.content.TryGetValue(key, out value!))
return true;
// Second, we check if the key is part of the other language packs, such as the assistant plugins:
foreach (var otherLanguagePlugin in this.otherLanguagePlugins)
if(otherLanguagePlugin.TryGetText(key, out value))
return true;
// Finally, we check if the key is part of the base language pack. This is the case,
// when a language plugin does not cover all keys. In this case, the base language plugin
// will be used to fill in the missing keys:
if(this.baseLanguage is not null && this.baseLanguage.TryGetText(key, out value))
return true;
value = string.Empty;
return false;
}
/// <summary>
/// Tries to initialize the IETF tag.
@@ -127,4 +100,71 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
message = string.Empty;
return true;
}
private bool TryInitLangName(out string message, out string readLangName)
{
if (!this.state.Environment["LANG_NAME"].TryRead(out readLangName))
{
message = "The field LANG_NAME does not exist or is not a valid string.";
readLangName = string.Empty;
return false;
}
if (string.IsNullOrWhiteSpace(readLangName))
{
message = "The field LANG_NAME is empty. Use a valid language name.";
readLangName = string.Empty;
return false;
}
message = string.Empty;
return true;
}
#region Implementation of ILanguagePlugin
/// <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>
/// <param name="logWarning">When true, a warning will be logged if the key does not exist.</param>
/// <returns>True if the key exists, false otherwise.</returns>
public bool TryGetText(string key, out string value, bool logWarning = false)
{
// First, we check if the key is part of the main language pack:
if (this.content.TryGetValue(key, out value!))
return true;
// Second, we check if the key is part of the other language packs, such as the assistant plugins:
foreach (var otherLanguagePlugin in this.otherLanguagePlugins)
if(otherLanguagePlugin.TryGetText(key, out value))
return true;
// Finally, we check if the key is part of the base language pack. This is the case,
// when a language plugin does not cover all keys. In this case, the base language plugin
// will be used to fill in the missing keys:
if(this.baseLanguage is not null && this.baseLanguage.TryGetText(key, out value))
return true;
if(logWarning)
LOGGER.LogWarning($"Missing translation key '{key}'.");
value = string.Empty;
return false;
}
/// <inheritdoc />
public string IETFTag => this.langCultureTag;
/// <inheritdoc />
public string LangName => this.langName;
#endregion
}
@@ -1,6 +1,6 @@
namespace AIStudio.Tools.PluginSystem;
public sealed class PluginMetadata(PluginBase plugin) : IPluginMetadata
public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin
{
#region Implementation of IPluginMetadata
@@ -47,4 +47,10 @@ public sealed class PluginMetadata(PluginBase plugin) : IPluginMetadata
public bool IsInternal { get; } = plugin.IsInternal;
#endregion
#region Implementation of IAvailablePlugin
public string LocalPath { get; } = localPath;
#endregion
}