From 81030019c7f375b6fb49f400ba1007a68e036cf6 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 26 Apr 2025 18:55:23 +0200 Subject: [PATCH] Added I18N assistant for localization of AI Studio content (#422) --- app/Build/Commands/CollectI18NKeysCommand.cs | 184 +++------ app/MindWork AI Studio.sln.DotSettings | 1 + .../Assistants/AssistantBase.razor | 6 +- .../Assistants/AssistantBase.razor.cs | 30 +- .../Assistants/I18N/AssistantI18N.razor | 124 +++++++ .../Assistants/I18N/AssistantI18N.razor.cs | 351 ++++++++++++++++++ .../Components/MSGComponentBase.cs | 1 + .../Dialogs/ProviderDialog.razor | 7 +- .../SettingsDialogAssistantBias.razor | 1 - .../Dialogs/Settings/SettingsDialogI18N.razor | 28 ++ .../Settings/SettingsDialogI18N.razor.cs | 5 + .../MindWork AI Studio.csproj | 4 - app/MindWork AI Studio/Pages/Assistants.razor | 12 +- .../Pages/Supporters.razor.cs | 2 - app/MindWork AI Studio/Routes.razor.cs | 1 + .../Settings/DataModel/Data.cs | 2 + .../Settings/DataModel/DataI18N.cs | 29 ++ app/MindWork AI Studio/Tools/ButtonData.cs | 17 +- .../Tools/CommonLanguageExtensions.cs | 18 + app/MindWork AI Studio/Tools/Components.cs | 4 + .../Tools/ComponentsExtensions.cs | 5 + .../Tools/PluginSystem/ILanguagePlugin.cs | 5 + .../Tools/PluginSystem/NoPluginLanguage.cs | 2 + .../PluginSystem/PluginFactory.Loading.cs | 14 +- .../Tools/PluginSystem/PluginLanguage.cs | 3 + .../wwwroot/changelog/v0.9.41.md | 1 + app/SharedTools/LuaTable.cs | 41 ++ app/SharedTools/LuaTools.cs | 10 + 28 files changed, 750 insertions(+), 158 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor create mode 100644 app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs create mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor create mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor.cs create mode 100644 app/MindWork AI Studio/Settings/DataModel/DataI18N.cs create mode 100644 app/SharedTools/LuaTable.cs create mode 100644 app/SharedTools/LuaTools.cs diff --git a/app/Build/Commands/CollectI18NKeysCommand.cs b/app/Build/Commands/CollectI18NKeysCommand.cs index d10ff0e0..896c606c 100644 --- a/app/Build/Commands/CollectI18NKeysCommand.cs +++ b/app/Build/Commands/CollectI18NKeysCommand.cs @@ -59,7 +59,7 @@ public sealed partial class CollectI18NKeysCommand Console.WriteLine($" {counter:###,###} files processed, {allI18NContent.Count:###,###} keys found."); Console.Write("- Creating Lua code ..."); - var luaCode = this.ExportToLuaTable(allI18NContent); + var luaCode = this.ExportToLuaAssignments(allI18NContent); // Build the path, where we want to store the Lua code: var luaPath = Path.Join(cwd, "Assistants", "I18N", "allTexts.lua"); @@ -69,134 +69,68 @@ public sealed partial class CollectI18NKeysCommand Console.WriteLine(" done."); } - - private string ExportToLuaTable(Dictionary keyValuePairs) - { - // Collect all nodes: - var root = new Dictionary(); - - // - // Split all collected keys into nodes: - // - foreach (var key in keyValuePairs.Keys.Order()) - { - var path = key.Split('.'); - var current = root; - for (var i = 0; i < path.Length - 1; i++) - { - // We ignore the AISTUDIO segment of the path: - if(path[i] == "AISTUDIO") - continue; - - if (!current.TryGetValue(path[i], out var child) || child is not Dictionary childDict) - { - childDict = new Dictionary(); - current[path[i]] = childDict; - } - - current = childDict; - } - - current[path.Last()] = keyValuePairs[key]; - } - // - // Inner method to build Lua code from the collected nodes: - // - void ToLuaTable(StringBuilder sb, Dictionary innerDict, int indent = 0) - { - sb.AppendLine("{"); - var prefix = new string(' ', indent * 4); - foreach (var kvp in innerDict) - { - if (kvp.Value is Dictionary childDict) - { - sb.Append($"{prefix} {kvp.Key}"); - sb.Append(" = "); - - ToLuaTable(sb, childDict, indent + 1); - } - else if (kvp.Value is string s) - { - sb.AppendLine($"{prefix} -- {s.Trim().Replace("\n", " ")}"); - sb.Append($"{prefix} {kvp.Key}"); - sb.Append(" = "); - sb.Append($""" - "{s}" - """); - sb.AppendLine(","); - sb.AppendLine(); - } - } + private string ExportToLuaAssignments(Dictionary keyValuePairs) + { + var sb = new StringBuilder(); + + // Add the mandatory plugin metadata: + sb.AppendLine( + """ + -- The ID for this plugin: + ID = "77c2688a-a68f-45cc-820e-fa8f3038a146" - sb.AppendLine(prefix + "},"); - sb.AppendLine(); - } + -- The icon for the plugin: + ICON_SVG = "" + + -- The name of the plugin: + NAME = "Collected I18N keys" + + -- The description of the plugin: + DESCRIPTION = "This plugin is not meant to be used directly. Its a collection of all I18N keys found in the project." + + -- 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 = "" + + -- The IETF BCP 47 tag for the language. It's the ISO 639 language + -- code followed by the ISO 3166-1 country code: + IETF_TAG = "en-US" + + -- The language name in the user's language: + LANG_NAME = "English (United States)" + + """ + ); - // - // Write the Lua code: - // - var sbLua = new StringBuilder(); - - // To make the later parsing easier, we add the mandatory plugin - // metadata: - sbLua.AppendLine( - """ - -- The ID for this plugin: - ID = "77c2688a-a68f-45cc-820e-fa8f3038a146" - - -- The icon for the plugin: - ICON_SVG = "" - - -- The name of the plugin: - NAME = "Collected I18N keys" - - -- The description of the plugin: - DESCRIPTION = "This plugin is not meant to be used directly. Its a collection of all I18N keys found in the project." - - -- 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 = "" - - -- The IETF BCP 47 tag for the language. It's the ISO 639 language - -- code followed by the ISO 3166-1 country code: - IETF_TAG = "en-US" - - -- The language name in the user's language: - LANG_NAME = "English (United States)" - - """); - - sbLua.Append("UI_TEXT_CONTENT = "); - if(root["UI_TEXT_CONTENT"] is Dictionary dict) - ToLuaTable(sbLua, dict); - - return sbLua.ToString(); + // Add the UI_TEXT_CONTENT table: + LuaTable.Create(ref sb, "UI_TEXT_CONTENT", keyValuePairs); + return sb.ToString(); } - + private List FindAllTextTags(ReadOnlySpan fileContent) { const string START_TAG = """ diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index a626186b..f8edfd31 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -13,6 +13,7 @@ RID UI URL + I18N True True True diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 38d4b8b8..c27704c4 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -29,7 +29,7 @@ - + @this.SubmitText @if (this.isProcessing && this.cancellationTokenSource is not null) @@ -97,14 +97,14 @@ { case ButtonData buttonData when !string.IsNullOrWhiteSpace(buttonData.Tooltip): - + @buttonData.Text break; case ButtonData buttonData: - + @buttonData.Text break; diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 1f0c7364..1a9ff997 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -97,10 +97,10 @@ public abstract partial class AssistantBase : AssistantLowerBase, IMe protected Profile currentProfile = Profile.NO_PROFILE; protected ChatThread? chatThread; protected IContent? lastUserPrompt; + protected CancellationTokenSource? cancellationTokenSource; private readonly Timer formChangeTimer = new(TimeSpan.FromSeconds(1.6)); - - private CancellationTokenSource? cancellationTokenSource; + private ContentBlock? resultingContentBlock; private string[] inputIssues = []; private bool isProcessing; @@ -179,6 +179,16 @@ public abstract partial class AssistantBase : AssistantLowerBase, IMe return null; } + private async Task Start() + { + using (this.cancellationTokenSource = new()) + { + await this.SubmitAction(); + } + + this.cancellationTokenSource = null; + } + private void TriggerFormChange(FormFieldChangedEventArgs _) { this.formChangeTimer.Stop(); @@ -286,16 +296,12 @@ public abstract partial class AssistantBase : AssistantLowerBase, IMe this.isProcessing = true; this.StateHasChanged(); - - using (this.cancellationTokenSource = new()) - { - // Use the selected provider to get the AI response. - // By awaiting this line, we wait for the entire - // content to be streamed. - this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(this.Logger), this.providerSettings.Model, this.lastUserPrompt, this.chatThread, this.cancellationTokenSource.Token); - } - - this.cancellationTokenSource = null; + + // Use the selected provider to get the AI response. + // By awaiting this line, we wait for the entire + // content to be streamed. + this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(this.Logger), this.providerSettings.Model, this.lastUserPrompt, this.chatThread, this.cancellationTokenSource!.Token); + this.isProcessing = false; this.StateHasChanged(); diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor new file mode 100644 index 00000000..9f19d966 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor @@ -0,0 +1,124 @@ +@attribute [Route(Routes.ASSISTANT_AI_STUDIO_I18N)] +@using AIStudio.Settings +@inherits AssistantBaseCore + + + +@if (this.isLoading) +{ + + The data is being loaded, please wait... + +} else if (!this.isLoading && !string.IsNullOrWhiteSpace(this.loadingIssue)) +{ + + While loading the I18N data, an issue occurred: @this.loadingIssue + +} +else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) +{ + + Added Content (@this.addedContent.Count entries) + + + + + + + + + + + Key + Text + + + +
+                    @context.Key
+                
+
+ + @context.Value + +
+ + + +
+ + + Removed Content (@this.removedContent.Count entries) + + + + + + + + + + + Key + Text + + + +
+                    @context.Key
+                
+
+ + @context.Value + +
+ + + +
+ + @if (this.selectedTargetLanguage is CommonLanguages.EN_US) + { + + Please note: neither is a translation needed nor performed for English (USA). Anyway, you might want to generate the related Lua code. + + } + else + { + + } + + @if (this.localizedContent.Count > 0) + { +
+ + Localized Content (@this.localizedContent.Count entries of @this.NumTotalItems) + + + + + + + + + + + Key + Text + + + +
+                    @context.Key
+                
+
+ + @context.Value + +
+ + + +
+ } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs new file mode 100644 index 00000000..3fc2b63c --- /dev/null +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs @@ -0,0 +1,351 @@ +using System.Diagnostics; +using System.Text; + +using AIStudio.Dialogs.Settings; +using AIStudio.Tools.PluginSystem; + +using Microsoft.Extensions.FileProviders; + +using SharedTools; + +namespace AIStudio.Assistants.I18N; + +public partial class AssistantI18N : AssistantBaseCore +{ + public override Tools.Components Component => Tools.Components.I18N_ASSISTANT; + + protected override string Title => "Localization"; + + protected override string Description => + """ + Translate MindWork AI Studio text content into another language. + """; + + protected override string SystemPrompt => + $""" + # Assignment + You are an expert in professional translations from English (US) to {this.SystemPromptLanguage()}. + You translate the texts without adding any new information. When necessary, you correct + spelling and grammar. + + # Context + The texts to be translated come from the open source app "MindWork AI Studio". The goal + is to localize the app so that it can be offered in other languages. You will always + receive one text at a time. A text may be, for example, for a button, a label, or an + explanation within the app. The app "AI Studio" is a desktop app for macOS, Linux, + and Windows. Users can use Large Language Models (LLMs) in practical ways in their + daily lives with it. The app offers the regular chat mode for which LLMs have become + known. However, AI Studio also offers so-called assistants, where users no longer + have to prompt. + + # Target Audience + The app is intended for everyone, not just IT specialists or scientists. When translating, + make sure the texts are easy for everyone to understand. + """; + + protected override bool AllowProfiles => false; + + protected override bool ShowResult => false; + + protected override bool ShowCopyResult => false; + + protected override bool ShowSendTo => false; + + protected override IReadOnlyList FooterButtons => + [ + new ButtonData + { + Text = "Copy Lua code to clipboard", + Icon = Icons.Material.Filled.Extension, + Color = Color.Default, + AsyncAction = async () => await this.RustService.CopyText2Clipboard(this.Snackbar, this.finalLuaCode.ToString()), + DisabledActionParam = () => this.finalLuaCode.Length == 0, + }, + ]; + + protected override string SubmitText => "Localize AI Studio & generate the Lua code"; + + protected override Func SubmitAction => this.LocalizeTextContent; + + protected override bool SubmitDisabled => !this.localizationPossible; + + protected override bool ShowDedicatedProgress => true; + + protected override void ResetForm() + { + if (!this.MightPreselectValues()) + { + this.selectedLanguagePluginId = InternalPlugin.LANGUAGE_EN_US.MetaData().Id; + this.selectedTargetLanguage = CommonLanguages.AS_IS; + this.customTargetLanguage = string.Empty; + } + + _ = this.OnChangedLanguage(); + } + + protected override bool MightPreselectValues() + { + if (this.SettingsManager.ConfigurationData.I18N.PreselectOptions) + { + this.selectedLanguagePluginId = this.SettingsManager.ConfigurationData.I18N.PreselectedLanguagePluginId; + this.selectedTargetLanguage = this.SettingsManager.ConfigurationData.I18N.PreselectedTargetLanguage; + this.customTargetLanguage = this.SettingsManager.ConfigurationData.I18N.PreselectOtherLanguage; + return true; + } + + return false; + } + + private CommonLanguages selectedTargetLanguage; + private string customTargetLanguage = string.Empty; + private bool isLoading = true; + private string loadingIssue = string.Empty; + private bool localizationPossible; + private string searchString = string.Empty; + private Guid selectedLanguagePluginId; + private ILanguagePlugin? selectedLanguagePlugin; + private Dictionary addedContent = []; + private Dictionary removedContent = []; + private Dictionary localizedContent = []; + private StringBuilder finalLuaCode = new(); + + #region Overrides of AssistantBase + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await this.OnLanguagePluginChanged(this.selectedLanguagePluginId); + await this.LoadData(); + } + + #endregion + + private string SystemPromptLanguage() => this.selectedTargetLanguage switch + { + CommonLanguages.OTHER => this.customTargetLanguage, + _ => $"{this.selectedTargetLanguage.Name()}", + }; + + private async Task OnLanguagePluginChanged(Guid pluginId) + { + this.selectedLanguagePluginId = pluginId; + await this.OnChangedLanguage(); + } + + private async Task OnChangedLanguage() + { + this.finalLuaCode.Clear(); + this.localizedContent.Clear(); + this.localizationPossible = false; + if (PluginFactory.RunningPlugins.FirstOrDefault(n => n is PluginLanguage && n.Id == this.selectedLanguagePluginId) is not PluginLanguage comparisonPlugin) + { + this.loadingIssue = $"Was not able to load the language plugin for comparison ({this.selectedLanguagePluginId}). Please select a valid, loaded & running language plugin."; + this.selectedLanguagePlugin = null; + } + else if (comparisonPlugin.IETFTag != this.selectedTargetLanguage.ToIETFTag()) + { + this.loadingIssue = $"The selected language plugin for comparison uses the IETF tag '{comparisonPlugin.IETFTag}' which does not match the selected target language '{this.selectedTargetLanguage.ToIETFTag()}'. Please select a valid, loaded & running language plugin which matches the target language."; + this.selectedLanguagePlugin = null; + } + else + { + this.selectedLanguagePlugin = comparisonPlugin; + this.loadingIssue = string.Empty; + await this.LoadData(); + } + + this.StateHasChanged(); + } + + private async Task LoadData() + { + if (this.selectedLanguagePlugin is null) + { + this.loadingIssue = "Please select a language plugin for comparison."; + this.localizationPossible = false; + this.isLoading = false; + this.StateHasChanged(); + return; + } + + this.isLoading = true; + this.StateHasChanged(); + + // + // Read the file `Assistants\I18N\allTexts.lua`: + // + #if DEBUG + var filePath = Path.Join(Environment.CurrentDirectory, "Assistants", "I18N"); + var resourceFileProvider = new PhysicalFileProvider(filePath); + #else + var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Assistants.I18N"); + #endif + + var file = resourceFileProvider.GetFileInfo("allTexts.lua"); + await using var fileStream = file.CreateReadStream(); + using var reader = new StreamReader(fileStream); + var newI18NDataLuaCode = await reader.ReadToEndAsync(); + + // + // Next, we try to load the text as a language plugin -- without + // actually starting the plugin: + // + var newI18NPlugin = await PluginFactory.Load(null, newI18NDataLuaCode); + switch (newI18NPlugin) + { + case NoPlugin noPlugin when noPlugin.Issues.Any(): + this.loadingIssue = noPlugin.Issues.First(); + break; + + case NoPlugin: + this.loadingIssue = "Was not able to load the I18N plugin. Please check the plugin code."; + break; + + case { IsValid: false } plugin when plugin.Issues.Any(): + this.loadingIssue = plugin.Issues.First(); + break; + + case PluginLanguage pluginLanguage: + this.loadingIssue = string.Empty; + var newI18NContent = pluginLanguage.Content; + + var currentI18NContent = this.selectedLanguagePlugin.Content; + this.addedContent = newI18NContent.ExceptBy(currentI18NContent.Keys, n => n.Key).ToDictionary(); + this.removedContent = currentI18NContent.ExceptBy(newI18NContent.Keys, n => n.Key).ToDictionary(); + this.localizationPossible = true; + break; + } + + this.isLoading = false; + this.StateHasChanged(); + } + + private bool FilterFunc(KeyValuePair element) + { + if (string.IsNullOrWhiteSpace(this.searchString)) + return true; + + if (element.Key.Contains(this.searchString, StringComparison.OrdinalIgnoreCase)) + return true; + + if (element.Value.Contains(this.searchString, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private string? ValidatingTargetLanguage(CommonLanguages language) + { + if(language == CommonLanguages.AS_IS) + return "Please select a target language."; + + return null; + } + + private string? ValidateCustomLanguage(string language) + { + if(this.selectedTargetLanguage == CommonLanguages.OTHER && string.IsNullOrWhiteSpace(language)) + return "Please provide a custom language."; + + return null; + } + + private int NumTotalItems => (this.selectedLanguagePlugin?.Content.Count ?? 0) + this.addedContent.Count - this.removedContent.Count; + + private async Task LocalizeTextContent() + { + await this.form!.Validate(); + if (!this.inputIsValid) + return; + + if(this.selectedLanguagePlugin is null) + return; + + if (this.selectedLanguagePlugin.IETFTag != this.selectedTargetLanguage.ToIETFTag()) + return; + + this.localizedContent.Clear(); + if (this.selectedTargetLanguage is not CommonLanguages.EN_US) + { + // Phase 1: Translate added content + await this.Phase1TranslateAddedContent(); + } + else + { + // Case: no translation needed + this.localizedContent = this.addedContent.ToDictionary(); + } + + if(this.cancellationTokenSource!.IsCancellationRequested) + return; + + // + // Now, we have localized the added content. Next, we must merge + // the localized content with the existing content. However, we + // must skip the removed content. We use the localizedContent + // dictionary for the final result: + // + foreach (var keyValuePair in this.selectedLanguagePlugin.Content) + { + if (this.cancellationTokenSource!.IsCancellationRequested) + break; + + if (this.localizedContent.ContainsKey(keyValuePair.Key)) + continue; + + if (this.removedContent.ContainsKey(keyValuePair.Key)) + continue; + + this.localizedContent.Add(keyValuePair.Key, keyValuePair.Value); + } + + if(this.cancellationTokenSource!.IsCancellationRequested) + return; + + // Phase 2: Create the Lua code + this.Phase2CreateLuaCode(); + } + + private async Task Phase1TranslateAddedContent() + { + var stopwatch = new Stopwatch(); + var minimumTime = TimeSpan.FromMilliseconds(500); + foreach (var keyValuePair in this.addedContent) + { + if(this.cancellationTokenSource!.IsCancellationRequested) + break; + + // + // We measure the time for each translation. + // We do not want to make more than 120 requests + // per minute, i.e., 2 requests per second. + // + stopwatch.Reset(); + stopwatch.Start(); + + // + // Translate one text at a time: + // + this.CreateChatThread(); + var time = this.AddUserRequest(keyValuePair.Value); + this.localizedContent.Add(keyValuePair.Key, await this.AddAIResponseAsync(time)); + + if (this.cancellationTokenSource!.IsCancellationRequested) + break; + + // + // Ensure that we do not exceed the rate limit of 2 requests per second: + // + stopwatch.Stop(); + if (stopwatch.Elapsed < minimumTime) + await Task.Delay(minimumTime - stopwatch.Elapsed); + } + } + + private void Phase2CreateLuaCode() + { + this.finalLuaCode.Clear(); + var commentContent = this.addedContent.Concat(PluginFactory.BaseLanguage.Content).ToDictionary(); + LuaTable.Create(ref this.finalLuaCode, "UI_TEXT_CONTENT", this.localizedContent, commentContent, this.cancellationTokenSource!.Token); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index 2ad3b96e..65d770c1 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -14,6 +14,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus protected MessageBus MessageBus { get; init; } = null!; [Inject] + // ReSharper disable once UnusedAutoPropertyAccessor.Local private ILogger Logger { get; init; } = null!; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor index 439a2dea..6e28d6fe 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor @@ -68,11 +68,12 @@ @inferenceProvider.ToName() - } + } - + @* ReSharper disable Asp.Entity *@ Please double-check if your model name matches the curl specifications provided by the inference provider. If it doesn't, you might get a Not Found error when trying to use the model. Here's a curl example. - } + @* ReSharper restore Asp.Entity *@ + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogAssistantBias.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogAssistantBias.razor index 389a6719..ec6776f0 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogAssistantBias.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogAssistantBias.razor @@ -1,5 +1,4 @@ @using AIStudio.Settings -@using AIStudio.Settings.DataModel @inherits SettingsDialogBase diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor new file mode 100644 index 00000000..6a89328b --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor @@ -0,0 +1,28 @@ +@using AIStudio.Settings +@inherits SettingsDialogBase + + + + + + @T("Assistant: Localization") + + + + + + + @if (this.SettingsManager.ConfigurationData.I18N.PreselectedTargetLanguage is CommonLanguages.OTHER) + { + + } + + + + + + + @T("Close") + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor.cs new file mode 100644 index 00000000..22cbbfb9 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogI18N.razor.cs @@ -0,0 +1,5 @@ +namespace AIStudio.Dialogs.Settings; + +public partial class SettingsDialogI18N : SettingsDialogBase +{ +} \ No newline at end of file diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index 2480a8fe..267f377e 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -61,10 +61,6 @@ - - - - diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 2acaa4ca..8106ad83 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -50,6 +50,16 @@ } - + + @if (PreviewFeatures.PRE_PLUGINS_2025.IsEnabled(this.SettingsManager)) + { + + AI Studio Development + + + + + } + \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Supporters.razor.cs b/app/MindWork AI Studio/Pages/Supporters.razor.cs index dcd937c9..bb82da0b 100644 --- a/app/MindWork AI Studio/Pages/Supporters.razor.cs +++ b/app/MindWork AI Studio/Pages/Supporters.razor.cs @@ -1,7 +1,5 @@ using AIStudio.Components; -using Microsoft.AspNetCore.Components; - namespace AIStudio.Pages; public partial class Supporters : MSGComponentBase; \ No newline at end of file diff --git a/app/MindWork AI Studio/Routes.razor.cs b/app/MindWork AI Studio/Routes.razor.cs index b6318820..d59bffac 100644 --- a/app/MindWork AI Studio/Routes.razor.cs +++ b/app/MindWork AI Studio/Routes.razor.cs @@ -26,5 +26,6 @@ public sealed partial class Routes public const string ASSISTANT_JOB_POSTING = "/assistant/job-posting"; public const string ASSISTANT_BIAS = "/assistant/bias-of-the-day"; public const string ASSISTANT_ERI = "/assistant/eri"; + public const string ASSISTANT_AI_STUDIO_I18N = "/assistant/ai-studio/i18n"; // ReSharper restore InconsistentNaming } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/Data.cs b/app/MindWork AI Studio/Settings/DataModel/Data.cs index b47eba49..439427bc 100644 --- a/app/MindWork AI Studio/Settings/DataModel/Data.cs +++ b/app/MindWork AI Studio/Settings/DataModel/Data.cs @@ -100,4 +100,6 @@ public sealed class Data public DataJobPostings JobPostings { get; init; } = new(); public DataBiasOfTheDay BiasOfTheDay { get; init; } = new(); + + public DataI18N I18N { get; init; } = new(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataI18N.cs b/app/MindWork AI Studio/Settings/DataModel/DataI18N.cs new file mode 100644 index 00000000..7f8ddb19 --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataI18N.cs @@ -0,0 +1,29 @@ +namespace AIStudio.Settings.DataModel; + +public class DataI18N +{ + /// + /// Preselect any I18N options? + /// + public bool PreselectOptions { get; set; } + + /// + /// Preselect a language plugin to where the new content should compare to? + /// + public Guid PreselectedLanguagePluginId { get; set; } + + /// + /// Preselect the target language? + /// + public CommonLanguages PreselectedTargetLanguage { get; set; } = CommonLanguages.EN_GB; + + /// + /// Preselect any other language? + /// + public string PreselectOtherLanguage { get; set; } = string.Empty; + + /// + /// Which LLM provider should be preselected? + /// + public string PreselectedProvider { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ButtonData.cs b/app/MindWork AI Studio/Tools/ButtonData.cs index 3d05dee8..da0c69c3 100644 --- a/app/MindWork AI Studio/Tools/ButtonData.cs +++ b/app/MindWork AI Studio/Tools/ButtonData.cs @@ -1,6 +1,21 @@ namespace AIStudio.Tools; -public readonly record struct ButtonData(string Text, string Icon, Color Color, string Tooltip, Func AsyncAction) : IButtonData +public readonly record struct ButtonData(string Text, string Icon, Color Color, string Tooltip, Func AsyncAction, Func? DisabledActionParam) : IButtonData { public ButtonTypes Type => ButtonTypes.BUTTON; + + public Func DisabledAction + { + get + { + var data = this; + return () => + { + if (data.DisabledActionParam is null) + return false; + + return data.DisabledActionParam(); + }; + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/CommonLanguageExtensions.cs b/app/MindWork AI Studio/Tools/CommonLanguageExtensions.cs index 96dfafe0..39de88e8 100644 --- a/app/MindWork AI Studio/Tools/CommonLanguageExtensions.cs +++ b/app/MindWork AI Studio/Tools/CommonLanguageExtensions.cs @@ -20,6 +20,24 @@ public static class CommonLanguageExtensions _ => "Other", }; + public static string ToIETFTag(this CommonLanguages language) => language switch + { + CommonLanguages.AS_IS => string.Empty, + + CommonLanguages.EN_US => "en-US", + CommonLanguages.EN_GB => "en-GB", + CommonLanguages.ZH_CN => "zh-CN", + CommonLanguages.HI_IN => "hi-IN", + CommonLanguages.ES_ES => "es-ES", + CommonLanguages.FR_FR => "fr-FR", + CommonLanguages.DE_DE => "de-DE", + CommonLanguages.DE_AT => "de-AT", + CommonLanguages.DE_CH => "de-CH", + CommonLanguages.JA_JP => "ja-JP", + + _ => string.Empty, + }; + public static string PromptSummarizing(this CommonLanguages language, string customLanguage) => language switch { CommonLanguages.AS_IS => "Do not change the language of the text.", diff --git a/app/MindWork AI Studio/Tools/Components.cs b/app/MindWork AI Studio/Tools/Components.cs index d65a5c5d..94148d5e 100644 --- a/app/MindWork AI Studio/Tools/Components.cs +++ b/app/MindWork AI Studio/Tools/Components.cs @@ -19,6 +19,10 @@ public enum Components BIAS_DAY_ASSISTANT, ERI_ASSISTANT, + // ReSharper disable InconsistentNaming + I18N_ASSISTANT, + // ReSharper restore InconsistentNaming + CHAT, APP_SETTINGS, diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 6112debb..2247b6e6 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -10,8 +10,11 @@ public static class ComponentsExtensions public static bool AllowSendTo(this Components component) => component switch { Components.NONE => false, + Components.ERI_ASSISTANT => false, Components.BIAS_DAY_ASSISTANT => false, + Components.I18N_ASSISTANT => false, + Components.APP_SETTINGS => false, Components.AGENT_TEXT_CONTENT_CLEANER => false, @@ -36,6 +39,7 @@ public static class ComponentsExtensions Components.MY_TASKS_ASSISTANT => "My Tasks Assistant", Components.JOB_POSTING_ASSISTANT => "Job Posting Assistant", Components.ERI_ASSISTANT => "ERI Server", + Components.I18N_ASSISTANT => "Localization Assistant", Components.CHAT => "New Chat", @@ -99,6 +103,7 @@ public static class ComponentsExtensions Components.JOB_POSTING_ASSISTANT => settingsManager.ConfigurationData.JobPostings.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.JobPostings.PreselectedProvider) : default, Components.BIAS_DAY_ASSISTANT => settingsManager.ConfigurationData.BiasOfTheDay.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.BiasOfTheDay.PreselectedProvider) : default, Components.ERI_ASSISTANT => settingsManager.ConfigurationData.ERI.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.ERI.PreselectedProvider) : default, + Components.I18N_ASSISTANT => settingsManager.ConfigurationData.I18N.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.I18N.PreselectedProvider) : default, Components.CHAT => settingsManager.ConfigurationData.Chat.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Chat.PreselectedProvider) : default, diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs index 4f214d68..869331da 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/ILanguagePlugin.cs @@ -29,4 +29,9 @@ public interface ILanguagePlugin /// Gets the name of the language. /// public string LangName { get; } + + /// + /// Get all keys and texts from the language plugin. + /// + public IReadOnlyDictionary Content { get; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/NoPluginLanguage.cs b/app/MindWork AI Studio/Tools/PluginSystem/NoPluginLanguage.cs index 26d35849..bb8ec4fc 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/NoPluginLanguage.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/NoPluginLanguage.cs @@ -22,5 +22,7 @@ public sealed class NoPluginLanguage : PluginBase, ILanguagePlugin public string LangName => string.Empty; + public IReadOnlyDictionary Content => new Dictionary(); + #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index b6a39b1f..fd9329cc 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -101,16 +101,18 @@ public static partial class PluginFactory } } - private static async Task Load(string pluginPath, string code, CancellationToken cancellationToken = default) + public static async Task Load(string? pluginPath, 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(); - - // Add the module loader so that the plugin can load other Lua modules: - state.ModuleLoader = new PluginLoader(pluginPath); - + if (!string.IsNullOrWhiteSpace(pluginPath)) + { + // Add the module loader so that the plugin can load other Lua modules: + state.ModuleLoader = new PluginLoader(pluginPath); + } + // Add some useful libraries: state.OpenModuleLibrary(); state.OpenStringLibrary(); @@ -141,7 +143,7 @@ public static partial class PluginFactory if(type is PluginType.NONE) return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues()}"); - var isInternal = pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase); + var isInternal = !string.IsNullOrWhiteSpace(pluginPath) && pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase); return type switch { PluginType.LANGUAGE => new PluginLanguage(isInternal, state, type), diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs index 1c201a11..f1a8ce5b 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginLanguage.cs @@ -165,6 +165,9 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin /// public string LangName => this.langName; + + /// + public IReadOnlyDictionary Content => this.content.AsReadOnly(); #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.9.41.md b/app/MindWork AI Studio/wwwroot/changelog/v0.9.41.md index 2e5fb449..51c24900 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v0.9.41.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.9.41.md @@ -1,5 +1,6 @@ # v0.9.41, build 216 (2025-0x-xx xx:xx UTC) - Added the user-language, as provided by the OS, to the about page. This helps in identifying user-specific issues related to language settings. +- Added the localization assistant as a preview feature behind the plugin preview flag. This helps AI Studio developers to translate AI Studio to different languages. - Changed the terminology from "temporary chats" to "disappearing chats" in the UI. This makes it clearer to understand the purpose of these chats. - Improved the hot reloading of the plugin system to prevent overlapping reloads. - Improved the app behavior when the user system was waked up from sleep mode. diff --git a/app/SharedTools/LuaTable.cs b/app/SharedTools/LuaTable.cs new file mode 100644 index 00000000..16d092b4 --- /dev/null +++ b/app/SharedTools/LuaTable.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace SharedTools; + +public static class LuaTable +{ + public static string Create(ref StringBuilder sb, string tableVariableName, IReadOnlyDictionary keyValuePairs, IReadOnlyDictionary? commentContent = null, CancellationToken cancellationToken = default) + { + // + // Add the UI_TEXT_CONTENT table: + // + sb.AppendLine($$"""{{tableVariableName}} = {}"""); + foreach (var kvp in keyValuePairs.OrderBy(x => x.Key)) + { + if (cancellationToken.IsCancellationRequested) + return sb.ToString(); + + var key = kvp.Key; + var value = kvp.Value.Replace("\n", " ").Trim(); + var commentValue = commentContent is null ? value : commentContent.GetValueOrDefault(key, value); + + // Remove the "UI_TEXT_CONTENT." prefix from the key: + const string UI_TEXT_CONTENT = "UI_TEXT_CONTENT."; + var keyWithoutPrefix = key.StartsWith(UI_TEXT_CONTENT, StringComparison.OrdinalIgnoreCase) ? key[UI_TEXT_CONTENT.Length..] : key; + + // Replace all dots in the key with colons: + keyWithoutPrefix = keyWithoutPrefix.Replace(".", "::"); + + // Add a comment with the original text content: + sb.AppendLine(); + sb.AppendLine($"-- {commentContent}"); + + // Add the assignment to the UI_TEXT_CONTENT table: + sb.AppendLine($""" + UI_TEXT_CONTENT["{keyWithoutPrefix}"] = "{LuaTools.EscapeLuaString(value)}" + """); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/app/SharedTools/LuaTools.cs b/app/SharedTools/LuaTools.cs new file mode 100644 index 00000000..53bd07c6 --- /dev/null +++ b/app/SharedTools/LuaTools.cs @@ -0,0 +1,10 @@ +namespace SharedTools; + +public static class LuaTools +{ + public static string EscapeLuaString(string value) + { + // Replace backslashes with double backslashes and escape double quotes: + return value.Replace("\\", @"\\").Replace("\"", "\\\""); + } +} \ No newline at end of file