Configure language & start language plugins (#400)
Some checks failed
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 2025-04-12 21:13:33 +02:00 committed by GitHub
parent 171ed37c27
commit 3fc15d9789
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 612 additions and 156 deletions

View File

@ -2,6 +2,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=EDI/@EntryIndexedValue">EDI</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=EDI/@EntryIndexedValue">EDI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ERI/@EntryIndexedValue">ERI</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ERI/@EntryIndexedValue">ERI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=FNV/@EntryIndexedValue">FNV</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=GWDG/@EntryIndexedValue">GWDG</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=GWDG/@EntryIndexedValue">GWDG</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HF/@EntryIndexedValue">HF</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HF/@EntryIndexedValue">HF</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LLM/@EntryIndexedValue">LLM</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LLM/@EntryIndexedValue">LLM</s:String>

View File

@ -841,7 +841,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
public override string ComponentName => nameof(ChatComponent); public override string ComponentName => nameof(ChatComponent);
public override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{ {
switch (triggeredEvent) switch (triggeredEvent)
{ {
@ -860,7 +860,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
} }
} }
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default protected override Task<TResult?> ProcessIncomingMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
{ {
switch (triggeredEvent) switch (triggeredEvent)
{ {

View File

@ -47,7 +47,7 @@ public partial class InnerScrolling : MSGComponentBase
public override string ComponentName => nameof(InnerScrolling); public override string ComponentName => nameof(InnerScrolling);
public override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{ {
switch (triggeredEvent) switch (triggeredEvent)
{ {
@ -59,11 +59,6 @@ public partial class InnerScrolling : MSGComponentBase
return Task.CompletedTask; return Task.CompletedTask;
} }
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
{
return Task.FromResult(default(TResult));
}
#endregion #endregion
private string MinWidthStyle => string.IsNullOrWhiteSpace(this.MinWidth) ? string.Empty : $"min-width: {this.MinWidth}; "; private string MinWidthStyle => string.IsNullOrWhiteSpace(this.MinWidth) ? string.Empty : $"min-width: {this.MinWidth}; ";

View File

@ -1,10 +1,11 @@
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace AIStudio.Components; namespace AIStudio.Components;
public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver, ILang
{ {
[Inject] [Inject]
protected SettingsManager SettingsManager { get; init; } = null!; protected SettingsManager SettingsManager { get; init; } = null!;
@ -12,12 +13,37 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
[Inject] [Inject]
protected MessageBus MessageBus { get; init; } = null!; protected MessageBus MessageBus { get; init; } = null!;
[Inject]
private ILogger<PluginLanguage> Logger { get; init; } = null!;
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
this.Lang = await this.SettingsManager.GetActiveLanguagePlugin();
this.MessageBus.RegisterComponent(this); this.MessageBus.RegisterComponent(this);
base.OnInitialized(); await base.OnInitializedAsync();
}
#endregion
#region Implementation of ILang
/// <inheritdoc />
public string T(string fallbackEN)
{
var type = this.GetType();
var ns = $"{type.Namespace!}::{type.Name}".ToUpperInvariant().Replace(".", "::");
var key = $"root::{ns}::T{fallbackEN.ToFNV32()}";
if(this.Lang.TryGetText(key, out var text, logWarning: false))
return text;
this.Logger.LogWarning($"Missing translation key '{key}' for content '{fallbackEN}'.");
return fallbackEN;
} }
#endregion #endregion
@ -26,7 +52,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
public abstract string ComponentName { get; } public abstract string ComponentName { get; }
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{ {
switch (triggeredEvent) switch (triggeredEvent)
{ {
@ -34,18 +60,33 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
this.StateHasChanged(); this.StateHasChanged();
break; break;
case Event.PLUGINS_RELOADED:
this.Lang = await this.SettingsManager.GetActiveLanguagePlugin();
await this.InvokeAsync(this.StateHasChanged);
break;
default: default:
return this.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); await this.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
break;
}
} }
public async Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data)
{
return await this.ProcessIncomingMessageWithResult<TPayload, TResult>(sendingComponent, triggeredEvent, data);
}
#endregion
protected virtual Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{
return Task.CompletedTask; return Task.CompletedTask;
} }
public abstract Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data); protected virtual Task<TResult?> ProcessIncomingMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data)
{
public abstract Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data); return Task.FromResult<TResult?>(default);
}
#endregion
#region Implementation of IDisposable #region Implementation of IDisposable
@ -71,7 +112,8 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
// Append the color theme changed event to the list of events: // Append the color theme changed event to the list of events:
var eventsList = new List<Event>(events) var eventsList = new List<Event>(events)
{ {
Event.COLOR_THEME_CHANGED Event.COLOR_THEME_CHANGED,
Event.PLUGINS_RELOADED,
}; };
this.MessageBus.ApplyFilters(this, components, eventsList.ToArray()); this.MessageBus.ApplyFilters(this, components, eventsList.ToArray());

View File

@ -3,14 +3,25 @@
@inherits SettingsPanelBase @inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="App Options"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="App Options">
@if (PreviewFeatures.PRE_PLUGINS_2025.IsEnabled(this.SettingsManager))
{
<ConfigurationSelect OptionDescription="Language behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.LanguageBehavior = selectedValue)" OptionHelp="Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?"/>
@if (this.SettingsManager.ConfigurationData.App.LanguageBehavior is LangBehavior.MANUAL)
{
<ConfigurationSelect OptionDescription="Language" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.LanguagePluginId = selectedValue)" OptionHelp="Select the language for the app."/>
}
}
<ConfigurationSelect OptionDescription="Color theme" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="Choose the color theme that best suits for you."/> <ConfigurationSelect OptionDescription="Color theme" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="Choose the color theme that best suits for you."/>
<ConfigurationOption OptionDescription="Save energy?" LabelOn="Energy saving is enabled" LabelOff="Energy saving is disabled" State="@(() => this.SettingsManager.ConfigurationData.App.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.IsSavingEnergy = updatedState)" OptionHelp="When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available."/> <ConfigurationOption OptionDescription="Save energy?" LabelOn="Energy saving is enabled" LabelOff="Energy saving is disabled" State="@(() => this.SettingsManager.ConfigurationData.App.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.IsSavingEnergy = updatedState)" OptionHelp="When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available."/>
<ConfigurationOption OptionDescription="Enable spellchecking?" LabelOn="Spellchecking is enabled" LabelOff="Spellchecking is disabled" State="@(() => this.SettingsManager.ConfigurationData.App.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.EnableSpellchecking = updatedState)" OptionHelp="When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." /> <ConfigurationOption OptionDescription="Enable spellchecking?" LabelOn="Spellchecking is enabled" LabelOff="Spellchecking is disabled" State="@(() => this.SettingsManager.ConfigurationData.App.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.EnableSpellchecking = updatedState)" OptionHelp="When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections."/>
<ConfigurationSelect OptionDescription="Check for updates" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateBehavior = selectedValue)" OptionHelp="How often should we check for app updates?"/> <ConfigurationSelect OptionDescription="Check for updates" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateBehavior = selectedValue)" OptionHelp="How often should we check for app updates?"/>
<ConfigurationSelect OptionDescription="Navigation bar behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.NavigationBehavior)" Data="@ConfigurationSelectDataFactory.GetNavBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.NavigationBehavior = selectedValue)" OptionHelp="Select the desired behavior for the navigation bar."/> <ConfigurationSelect OptionDescription="Navigation bar behavior" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.NavigationBehavior)" Data="@ConfigurationSelectDataFactory.GetNavBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.NavigationBehavior = selectedValue)" OptionHelp="Select the desired behavior for the navigation bar."/>
<ConfigurationSelect OptionDescription="Preview feature visibility" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreviewVisibility)" Data="@ConfigurationSelectDataFactory.GetPreviewVisibility()" SelectionUpdate="@this.UpdatePreviewFeatures" OptionHelp="Do you want to show preview features in the app?"/> <ConfigurationSelect OptionDescription="Preview feature visibility" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreviewVisibility)" Data="@ConfigurationSelectDataFactory.GetPreviewVisibility()" SelectionUpdate="@this.UpdatePreviewFeatures" OptionHelp="Do you want to show preview features in the app?"/>
@if(this.SettingsManager.ConfigurationData.App.PreviewVisibility > PreviewVisibility.NONE) @if (this.SettingsManager.ConfigurationData.App.PreviewVisibility > PreviewVisibility.NONE)
{ {
var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList(); var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList();
if (availablePreviewFeatures.Count > 0) if (availablePreviewFeatures.Count > 0)

View File

@ -83,26 +83,13 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDis
// Ensure that all settings are loaded: // Ensure that all settings are loaded:
await this.SettingsManager.LoadSettings(); await this.SettingsManager.LoadSettings();
//
// We cannot process the plugins before the settings are loaded,
// and we know our data directory.
//
if(PreviewFeatures.PRE_PLUGINS_2025.IsEnabled(this.SettingsManager))
{
// Ensure that all internal plugins are present:
await PluginFactory.EnsureInternalPlugins();
// Load (but not start) all plugins, without waiting for them:
var pluginLoadingTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = PluginFactory.LoadAll(pluginLoadingTimeout.Token);
// Set up hot reloading for plugins:
PluginFactory.SetUpHotReloading();
}
// Register this component with the message bus: // Register this component with the message bus:
this.MessageBus.RegisterComponent(this); this.MessageBus.RegisterComponent(this);
this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR ]); this.MessageBus.ApplyFilters(this, [],
[
Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR,
Event.STARTUP_PLUGIN_SYSTEM, Event.PLUGINS_RELOADED
]);
// Set the snackbar for the update service: // Set the snackbar for the update service:
UpdateService.SetBlazorDependencies(this.Snackbar); UpdateService.SetBlazorDependencies(this.Snackbar);
@ -115,6 +102,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDis
// Solve issue https://github.com/MudBlazor/MudBlazor/issues/11133: // Solve issue https://github.com/MudBlazor/MudBlazor/issues/11133:
MudGlobal.TooltipDefaults.Duration = TimeSpan.Zero; MudGlobal.TooltipDefaults.Duration = TimeSpan.Zero;
// Send a message to start the plugin system:
await this.MessageBus.SendMessage<bool>(this, Event.STARTUP_PLUGIN_SYSTEM);
await this.themeProvider.WatchSystemPreference(this.SystemeThemeChanged); await this.themeProvider.WatchSystemPreference(this.SystemeThemeChanged);
await this.UpdateThemeConfiguration(); await this.UpdateThemeConfiguration();
this.LoadNavItems(); this.LoadNavItems();
@ -179,6 +169,32 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDis
error.Show(this.Snackbar); error.Show(this.Snackbar);
break; break;
case Event.STARTUP_PLUGIN_SYSTEM:
if(PreviewFeatures.PRE_PLUGINS_2025.IsEnabled(this.SettingsManager))
{
_ = Task.Run(async () =>
{
// Set up the plugin system:
PluginFactory.Setup();
// Ensure that all internal plugins are present:
await PluginFactory.EnsureInternalPlugins();
// Load (but not start) all plugins, without waiting for them:
var pluginLoadingTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await PluginFactory.LoadAll(pluginLoadingTimeout.Token);
// Set up hot reloading for plugins:
PluginFactory.SetUpHotReloading();
});
}
break;
case Event.PLUGINS_RELOADED:
await this.InvokeAsync(this.StateHasChanged);
break;
} }
} }

View File

@ -7,11 +7,11 @@
<MudText Typo="Typo.h3" Class="mb-2 mr-3"> <MudText Typo="Typo.h3" Class="mb-2 mr-3">
@if (this.chatThread is not null && this.chatThread.WorkspaceId != Guid.Empty) @if (this.chatThread is not null && this.chatThread.WorkspaceId != Guid.Empty)
{ {
@($"Chat in Workspace \"{this.currentWorkspaceName}\"") @(T("Chat in Workspace") + $" \"{this.currentWorkspaceName}\"")
} }
else else
{ {
@("Temporary Chat") @(T("Short-Term Chat"))
} }
</MudText> </MudText>

View File

@ -84,7 +84,7 @@ public partial class Chat : MSGComponentBase
public override string ComponentName => nameof(Chat); public override string ComponentName => nameof(Chat);
public override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{ {
switch (triggeredEvent) switch (triggeredEvent)
{ {
@ -96,10 +96,5 @@ public partial class Chat : MSGComponentBase
return Task.CompletedTask; return Task.CompletedTask;
} }
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
{
return Task.FromResult(default(TResult));
}
#endregion #endregion
} }

View File

@ -1,8 +1,11 @@
@attribute [Route(Routes.HOME)] @attribute [Route(Routes.HOME)]
@inherits MSGComponentBase
<div class="inner-scrolling-context"> <div class="inner-scrolling-context">
<MudImage Src="svg/banner.svg" Style="max-height: 16em; width: 100%; object-fit: cover;" /> <MudImage Src="svg/banner.svg" Style="max-height: 16em; width: 100%; object-fit: cover;" />
<MudText Typo="Typo.h3" Class="mt-2 mb-2">Let's get started</MudText> <MudText Typo="Typo.h3" Class="mt-2 mb-2">
@T("Let's get started")
</MudText>
<InnerScrolling> <InnerScrolling>
<MudExpansionPanels Class="mb-3" MultiExpansion="@false"> <MudExpansionPanels Class="mb-3" MultiExpansion="@false">

View File

@ -6,10 +6,10 @@ using Changelog = AIStudio.Components.Changelog;
namespace AIStudio.Pages; namespace AIStudio.Pages;
public partial class Home : ComponentBase public partial class Home : MSGComponentBase
{ {
[Inject] [Inject]
private HttpClient HttpClient { get; set; } = null!; private HttpClient HttpClient { get; init; } = null!;
private string LastChangeContent { get; set; } = string.Empty; private string LastChangeContent { get; set; } = string.Empty;
@ -23,6 +23,12 @@ public partial class Home : ComponentBase
#endregion #endregion
#region Overrides of MSGComponentBase
public override string ComponentName => nameof(Home);
#endregion
private async Task ReadLastChangeAsync() private async Task ReadLastChangeAsync()
{ {
var latest = Changelog.LOGS.MaxBy(n => n.Build); var latest = Changelog.LOGS.MaxBy(n => n.Build);

View File

@ -1,4 +1,5 @@
@using AIStudio.Tools.PluginSystem @using AIStudio.Tools.PluginSystem
@inherits MSGComponentBase
@attribute [Route(Routes.PLUGINS)] @attribute [Route(Routes.PLUGINS)]
<div class="inner-scrolling-context"> <div class="inner-scrolling-context">

View File

@ -1,22 +1,14 @@
using AIStudio.Settings; using AIStudio.Components;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Pages; namespace AIStudio.Pages;
public partial class Plugins : ComponentBase, IMessageBusReceiver public partial class Plugins : MSGComponentBase
{ {
private const string GROUP_ENABLED = "Enabled"; private const string GROUP_ENABLED = "Enabled";
private const string GROUP_DISABLED = "Disabled"; private const string GROUP_DISABLED = "Disabled";
private const string GROUP_INTERNAL = "Internal"; private const string GROUP_INTERNAL = "Internal";
[Inject]
private MessageBus MessageBus { get; init; } = null!;
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
private TableGroupDefinition<IPluginMetadata> groupConfig = null!; private TableGroupDefinition<IPluginMetadata> groupConfig = null!;
#region Overrides of ComponentBase #region Overrides of ComponentBase
@ -46,6 +38,12 @@ public partial class Plugins : ComponentBase, IMessageBusReceiver
#endregion #endregion
#region Overrides of MSGComponentBase
public override string ComponentName => nameof(Plugins);
#endregion
private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta) private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta)
{ {
if (this.SettingsManager.IsPluginEnabled(pluginMeta)) if (this.SettingsManager.IsPluginEnabled(pluginMeta))
@ -56,27 +54,4 @@ public partial class Plugins : ComponentBase, IMessageBusReceiver
await this.SettingsManager.StoreSettings(); await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED); await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
} }
#region Implementation of IMessageBusReceiver
public string ComponentName => nameof(Plugins);
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{
switch (triggeredEvent)
{
case Event.PLUGINS_RELOADED:
this.InvokeAsync(this.StateHasChanged);
break;
}
return Task.CompletedTask;
}
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data)
{
return Task.FromResult<TResult?>(default);
}
#endregion
} }

View File

@ -43,16 +43,6 @@ public partial class Writer : MSGComponentBase, IAsyncDisposable
public override string ComponentName => nameof(Writer); public override string ComponentName => nameof(Writer);
public override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
return Task.CompletedTask;
}
public override Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default
{
return Task.FromResult(default(TResult));
}
#endregion #endregion
private bool IsProviderSelected => this.providerSettings.UsedLLMProvider != LLMProviders.NONE; private bool IsProviderSelected => this.providerSettings.UsedLLMProvider != LLMProviders.NONE;

View File

@ -44,6 +44,20 @@ DEPRECATION_MESSAGE = ""
-- code followed by the ISO 3166-1 country code: -- code followed by the ISO 3166-1 country code:
IETF_TAG = "de-DE" IETF_TAG = "de-DE"
-- The language name in the user's language:
LANG_NAME = "Deutsch (Deutschland)"
UI_TEXT_CONTENT = { UI_TEXT_CONTENT = {
HOME = CONTENT_HOME, HOME = CONTENT_HOME,
AISTUDIO = {
PAGES = {
HOME = {
T2331588413 = "Lass uns anfangen",
},
CHAT = {
T3718856736 = "Vorläufiger Chat",
}
},
}
} }

View File

@ -44,6 +44,20 @@ DEPRECATION_MESSAGE = ""
-- code followed by the ISO 3166-1 country code: -- code followed by the ISO 3166-1 country code:
IETF_TAG = "en-US" IETF_TAG = "en-US"
-- The language name in the user's language:
LANG_NAME = "English (United States)"
UI_TEXT_CONTENT = { UI_TEXT_CONTENT = {
HOME = CONTENT_HOME, HOME = CONTENT_HOME,
AISTUDIO = {
PAGES = {
HOME = {
T2331588413 = "Let's get started",
},
CHAT = {
T3718856736 = "Short-Term Chat",
},
},
}
} }

View File

@ -6,6 +6,7 @@ using AIStudio.Assistants.TextSummarizer;
using AIStudio.Assistants.EMail; using AIStudio.Assistants.EMail;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using WritingStylesRewrite = AIStudio.Assistants.RewriteImprove.WritingStyles; using WritingStylesRewrite = AIStudio.Assistants.RewriteImprove.WritingStyles;
using WritingStylesEMail = AIStudio.Assistants.EMail.WritingStyles; using WritingStylesEMail = AIStudio.Assistants.EMail.WritingStyles;
@ -25,6 +26,21 @@ public readonly record struct ConfigurationSelectData<T>(string Name, T Value);
/// </summary> /// </summary>
public static class ConfigurationSelectDataFactory public static class ConfigurationSelectDataFactory
{ {
public static IEnumerable<ConfigurationSelectData<LangBehavior>> GetLangBehaviorData()
{
foreach (var behavior in Enum.GetValues<LangBehavior>())
yield return new(behavior.Name(), behavior);
}
public static IEnumerable<ConfigurationSelectData<Guid>> GetLanguagesData()
{
foreach (var runningPlugin in PluginFactory.RunningPlugins)
{
if(runningPlugin is ILanguagePlugin languagePlugin)
yield return new(languagePlugin.LangName, runningPlugin.Id);
}
}
public static IEnumerable<ConfigurationSelectData<LoadingChatProviderBehavior>> GetLoadingChatProviderBehavior() public static IEnumerable<ConfigurationSelectData<LoadingChatProviderBehavior>> GetLoadingChatProviderBehavior()
{ {
yield return new("When possible, use the LLM provider which was used for each chat in the first place", LoadingChatProviderBehavior.USE_CHAT_PROVIDER_IF_AVAILABLE); yield return new("When possible, use the LLM provider which was used for each chat in the first place", LoadingChatProviderBehavior.USE_CHAT_PROVIDER_IF_AVAILABLE);

View File

@ -2,6 +2,16 @@ namespace AIStudio.Settings.DataModel;
public sealed class DataApp public sealed class DataApp
{ {
/// <summary>
/// The language behavior.
/// </summary>
public LangBehavior LanguageBehavior { get; set; } = LangBehavior.AUTO;
/// <summary>
/// The language plugin ID to use.
/// </summary>
public Guid LanguagePluginId { get; set; } = Guid.Empty;
/// <summary> /// <summary>
/// The preferred theme to use. /// The preferred theme to use.
/// </summary> /// </summary>

View File

@ -0,0 +1,7 @@
namespace AIStudio.Settings.DataModel;
public enum LangBehavior
{
AUTO,
MANUAL,
}

View File

@ -0,0 +1,12 @@
namespace AIStudio.Settings.DataModel;
public static class LangBehaviorExtensions
{
public static string Name(this LangBehavior langBehavior) => langBehavior switch
{
LangBehavior.AUTO => "Choose the language automatically, based on your system language.",
LangBehavior.MANUAL => "Choose the language manually.",
_ => "Unknown option"
};
}

View File

@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
// ReSharper disable NotAccessedPositionalProperty.Local // ReSharper disable NotAccessedPositionalProperty.Local
@ -13,7 +14,7 @@ namespace AIStudio.Settings;
/// <summary> /// <summary>
/// The settings manager. /// The settings manager.
/// </summary> /// </summary>
public sealed class SettingsManager(ILogger<SettingsManager> logger) public sealed class SettingsManager(ILogger<SettingsManager> logger, RustService rustService)
{ {
private const string SETTINGS_FILENAME = "settings.json"; private const string SETTINGS_FILENAME = "settings.json";
@ -24,6 +25,7 @@ public sealed class SettingsManager(ILogger<SettingsManager> logger)
}; };
private readonly ILogger<SettingsManager> logger = logger; private readonly ILogger<SettingsManager> logger = logger;
private readonly RustService rustService = rustService;
/// <summary> /// <summary>
/// The directory where the configuration files are stored. /// The directory where the configuration files are stored.
@ -143,8 +145,53 @@ public sealed class SettingsManager(ILogger<SettingsManager> logger)
return minimumLevel; return minimumLevel;
} }
/// <summary>
/// Checks if the given plugin is enabled.
/// </summary>
/// <param name="plugin">The plugin to check.</param>
/// <returns>True, when the plugin is enabled, false otherwise.</returns>
public bool IsPluginEnabled(IPluginMetadata plugin) => this.ConfigurationData.EnabledPlugins.Contains(plugin.Id); public bool IsPluginEnabled(IPluginMetadata plugin) => this.ConfigurationData.EnabledPlugins.Contains(plugin.Id);
/// <summary>
/// Returns the active language plugin.
/// </summary>
/// <returns>The active language plugin.</returns>
public async Task<ILanguagePlugin> GetActiveLanguagePlugin()
{
switch (this.ConfigurationData.App.LanguageBehavior)
{
case LangBehavior.AUTO:
var languageCode = await this.rustService.ReadUserLanguage();
var languagePlugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x is ILanguagePlugin langPlug && langPlug.IETFTag == languageCode);
if (languagePlugin is null)
return PluginFactory.BaseLanguage;
if (languagePlugin is ILanguagePlugin langPlugin)
return langPlugin;
this.logger.LogError("The language plugin is not a language plugin.");
return PluginFactory.BaseLanguage;
case LangBehavior.MANUAL:
var pluginId = this.ConfigurationData.App.LanguagePluginId;
var plugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x.Id == pluginId);
if (plugin is null)
{
this.logger.LogWarning($"The chosen language plugin (id='{pluginId}') is not available.");
return PluginFactory.BaseLanguage;
}
if (plugin is ILanguagePlugin chosenLangPlugin)
return chosenLangPlugin;
this.logger.LogError("The chosen language plugin is not a language plugin.");
return PluginFactory.BaseLanguage;
}
this.logger.LogError("The language behavior is unknown.");
return PluginFactory.BaseLanguage;
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false) public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false)
{ {

View File

@ -8,6 +8,7 @@ public enum Event
STATE_HAS_CHANGED, STATE_HAS_CHANGED,
CONFIGURATION_CHANGED, CONFIGURATION_CHANGED,
COLOR_THEME_CHANGED, COLOR_THEME_CHANGED,
STARTUP_PLUGIN_SYSTEM,
PLUGINS_RELOADED, PLUGINS_RELOADED,
SHOW_ERROR, SHOW_ERROR,

View File

@ -0,0 +1,62 @@
// ReSharper disable MemberCanBePrivate.Global
namespace AIStudio.Tools;
/// <summary>
/// Implements the FowlerNollVo 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;
}
}

View File

@ -0,0 +1,6 @@
namespace AIStudio.Tools.PluginSystem;
public interface IAvailablePlugin : IPluginMetadata
{
public string LocalPath { get; }
}

View File

@ -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);
}

View File

@ -16,6 +16,17 @@ public interface ILanguagePlugin
/// </remarks> /// </remarks>
/// <param name="key">The key to use to get the text.</param> /// <param name="key">The key to use to get the text.</param>
/// <param name="value">The desired 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> /// <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; }
} }

View File

@ -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
}

View File

@ -4,6 +4,12 @@ public static partial class PluginFactory
{ {
public static void SetUpHotReloading() 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}'."); LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
try try
{ {

View File

@ -10,6 +10,12 @@ public static partial class PluginFactory
{ {
public static async Task EnsureInternalPlugins() 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."); LOG.LogInformation("Start ensuring internal plugins.");
foreach (var plugin in Enum.GetValues<InternalPlugin>()) foreach (var plugin in Enum.GetValues<InternalPlugin>())
{ {
@ -40,15 +46,15 @@ public static partial class PluginFactory
} }
// Ensure that the additional resources exist: // 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."); LOG.LogError("The plugin contains a directory. This is not allowed.");
continue; continue;
} }
await CopyInternalPluginFile(content, metaData); await CopyInternalPluginFile(contentFilePath, metaData);
} }
} }
catch 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()); var pluginTypeBasePath = Path.Join(INTERNAL_PLUGINS_ROOT, metaData.Type.GetDirectory());
@ -73,7 +79,7 @@ public static partial class PluginFactory
if (!Directory.Exists(pluginPath)) if (!Directory.Exists(pluginPath))
Directory.CreateDirectory(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 using var outputStream = File.Create(pluginFilePath);
await inputStream.CopyToAsync(outputStream); await inputStream.CopyToAsync(outputStream);

View File

@ -9,29 +9,45 @@ namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory public static partial class PluginFactory
{ {
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger("PluginFactory"); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly List<IAvailablePlugin> AVAILABLE_PLUGINS = [];
private static readonly List<PluginBase> RUNNING_PLUGINS = [];
private static readonly string DATA_DIR = SettingsManager.DataDirectory!; private static bool IS_INITIALIZED;
private static string DATA_DIR = string.Empty;
private static readonly string PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins"); private static string PLUGINS_ROOT = string.Empty;
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
private static readonly string INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal"); private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
private static readonly FileSystemWatcher HOT_RELOAD_WATCHER;
private static readonly List<IPluginMetadata> AVAILABLE_PLUGINS = [];
/// <summary> /// <summary>
/// A list of all available plugins. /// A list of all available plugins.
/// </summary> /// </summary>
public static IReadOnlyCollection<IPluginMetadata> AvailablePlugins => AVAILABLE_PLUGINS; public static IReadOnlyCollection<IPluginMetadata> AvailablePlugins => AVAILABLE_PLUGINS;
static PluginFactory() /// <summary>
/// A list of all running plugins.
/// </summary>
public static IReadOnlyCollection<PluginBase> RunningPlugins => RUNNING_PLUGINS;
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)) if (!Directory.Exists(PLUGINS_ROOT))
Directory.CreateDirectory(PLUGINS_ROOT); Directory.CreateDirectory(PLUGINS_ROOT);
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT); HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
IS_INITIALIZED = true;
} }
/// <summary> /// <summary>
@ -48,6 +64,12 @@ public static partial class PluginFactory
/// </remarks> /// </remarks>
public static async Task LoadAll(CancellationToken cancellationToken = default) 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."); LOG.LogInformation("Start loading plugins.");
if (!Directory.Exists(PLUGINS_ROOT)) 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)}')"); 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) private static async Task<PluginBase> Load(string pluginPath, string code, CancellationToken cancellationToken = default)
@ -149,8 +174,99 @@ public static partial class PluginFactory
}; };
} }
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() public static void Dispose()
{ {
if(!IS_INITIALIZED)
return;
HOT_RELOAD_WATCHER.Dispose(); HOT_RELOAD_WATCHER.Dispose();
} }
} }

View File

@ -4,9 +4,12 @@ namespace AIStudio.Tools.PluginSystem;
public sealed class PluginLanguage : PluginBase, ILanguagePlugin 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 Dictionary<string, string> content = [];
private readonly List<ILanguagePlugin> otherLanguagePlugins = []; private readonly List<ILanguagePlugin> otherLanguagePlugins = [];
private readonly string langCultureTag; private readonly string langCultureTag;
private readonly string langName;
private ILanguagePlugin? baseLanguage; private ILanguagePlugin? baseLanguage;
@ -15,6 +18,9 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
if(!this.TryInitIETFTag(out var issue, out this.langCultureTag)) if(!this.TryInitIETFTag(out var issue, out this.langCultureTag))
this.pluginIssues.Add(issue); this.pluginIssues.Add(issue);
if(!this.TryInitLangName(out issue, out this.langName))
this.pluginIssues.Add(issue);
if (this.TryInitUITextContent(out issue, out var readContent)) if (this.TryInitUITextContent(out issue, out var readContent))
this.content = readContent; this.content = readContent;
else else
@ -37,39 +43,6 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
/// <param name="languagePlugin">The language plugin to add.</param> /// <param name="languagePlugin">The language plugin to add.</param>
public void AddOtherLanguagePlugin(ILanguagePlugin languagePlugin) => this.otherLanguagePlugins.Add(languagePlugin); 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> /// <summary>
/// Tries to initialize the IETF tag. /// Tries to initialize the IETF tag.
/// </summary> /// </summary>
@ -127,4 +100,71 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
message = string.Empty; message = string.Empty;
return true; 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
} }

View File

@ -1,6 +1,6 @@
namespace AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.PluginSystem;
public sealed class PluginMetadata(PluginBase plugin) : IPluginMetadata public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin
{ {
#region Implementation of IPluginMetadata #region Implementation of IPluginMetadata
@ -47,4 +47,10 @@ public sealed class PluginMetadata(PluginBase plugin) : IPluginMetadata
public bool IsInternal { get; } = plugin.IsInternal; public bool IsInternal { get; } = plugin.IsInternal;
#endregion #endregion
#region Implementation of IAvailablePlugin
public string LocalPath { get; } = localPath;
#endregion
} }