Added I18N assistant for localization of AI Studio content (#422)
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2025-04-26 18:55:23 +02:00 committed by GitHub
parent 201fb2514d
commit 81030019c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 750 additions and 158 deletions

View File

@ -59,7 +59,7 @@ public sealed partial class CollectI18NKeysCommand
Console.WriteLine($" {counter:###,###} files processed, {allI18NContent.Count:###,###} keys found."); Console.WriteLine($" {counter:###,###} files processed, {allI18NContent.Count:###,###} keys found.");
Console.Write("- Creating Lua code ..."); 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: // Build the path, where we want to store the Lua code:
var luaPath = Path.Join(cwd, "Assistants", "I18N", "allTexts.lua"); var luaPath = Path.Join(cwd, "Assistants", "I18N", "allTexts.lua");
@ -69,134 +69,68 @@ public sealed partial class CollectI18NKeysCommand
Console.WriteLine(" done."); Console.WriteLine(" done.");
} }
private string ExportToLuaTable(Dictionary<string, string> keyValuePairs)
{
// Collect all nodes:
var root = new Dictionary<string, object>();
//
// 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<string, object> childDict)
{
childDict = new Dictionary<string, object>();
current[path[i]] = childDict;
}
current = childDict;
}
current[path.Last()] = keyValuePairs[key];
}
// private string ExportToLuaAssignments(Dictionary<string, string> keyValuePairs)
// Inner method to build Lua code from the collected nodes: {
// var sb = new StringBuilder();
void ToLuaTable(StringBuilder sb, Dictionary<string, object> innerDict, int indent = 0)
{ // Add the mandatory plugin metadata:
sb.AppendLine("{"); sb.AppendLine(
var prefix = new string(' ', indent * 4); """
foreach (var kvp in innerDict) -- The ID for this plugin:
{ ID = "77c2688a-a68f-45cc-820e-fa8f3038a146"
if (kvp.Value is Dictionary<string, object> 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();
}
}
sb.AppendLine(prefix + "},"); -- The icon for the plugin:
sb.AppendLine(); 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)"
"""
);
// // Add the UI_TEXT_CONTENT table:
// Write the Lua code: LuaTable.Create(ref sb, "UI_TEXT_CONTENT", keyValuePairs);
// return sb.ToString();
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<string, object> dict)
ToLuaTable(sbLua, dict);
return sbLua.ToString();
} }
private List<string> FindAllTextTags(ReadOnlySpan<char> fileContent) private List<string> FindAllTextTags(ReadOnlySpan<char> fileContent)
{ {
const string START_TAG = """ const string START_TAG = """

View File

@ -13,6 +13,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=RID/@EntryIndexedValue">RID</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=RID/@EntryIndexedValue">RID</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UI/@EntryIndexedValue">UI</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UI/@EntryIndexedValue">UI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=URL/@EntryIndexedValue">URL</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=URL/@EntryIndexedValue">URL</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=I18N/@EntryIndexedValue">I18N</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=agentic/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=agentic/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=groq/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=groq/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=gwdg/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=gwdg/@EntryIndexedValue">True</s:Boolean>

View File

@ -29,7 +29,7 @@
</CascadingValue> </CascadingValue>
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3"> <MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3">
<MudButton Disabled="@this.SubmitDisabled" Variant="Variant.Filled" OnClick="() => this.SubmitAction()" Style="@this.SubmitButtonStyle"> <MudButton Disabled="@this.SubmitDisabled" Variant="Variant.Filled" OnClick="async () => await this.Start()" Style="@this.SubmitButtonStyle">
@this.SubmitText @this.SubmitText
</MudButton> </MudButton>
@if (this.isProcessing && this.cancellationTokenSource is not null) @if (this.isProcessing && this.cancellationTokenSource is not null)
@ -97,14 +97,14 @@
{ {
case ButtonData buttonData when !string.IsNullOrWhiteSpace(buttonData.Tooltip): case ButtonData buttonData when !string.IsNullOrWhiteSpace(buttonData.Tooltip):
<MudTooltip Text="@buttonData.Tooltip"> <MudTooltip Text="@buttonData.Tooltip">
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()"> <MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()">
@buttonData.Text @buttonData.Text
</MudButton> </MudButton>
</MudTooltip> </MudTooltip>
break; break;
case ButtonData buttonData: case ButtonData buttonData:
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()"> <MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()">
@buttonData.Text @buttonData.Text
</MudButton> </MudButton>
break; break;

View File

@ -97,10 +97,10 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase, IMe
protected Profile currentProfile = Profile.NO_PROFILE; protected Profile currentProfile = Profile.NO_PROFILE;
protected ChatThread? chatThread; protected ChatThread? chatThread;
protected IContent? lastUserPrompt; protected IContent? lastUserPrompt;
protected CancellationTokenSource? cancellationTokenSource;
private readonly Timer formChangeTimer = new(TimeSpan.FromSeconds(1.6)); private readonly Timer formChangeTimer = new(TimeSpan.FromSeconds(1.6));
private CancellationTokenSource? cancellationTokenSource;
private ContentBlock? resultingContentBlock; private ContentBlock? resultingContentBlock;
private string[] inputIssues = []; private string[] inputIssues = [];
private bool isProcessing; private bool isProcessing;
@ -179,6 +179,16 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase, IMe
return null; return null;
} }
private async Task Start()
{
using (this.cancellationTokenSource = new())
{
await this.SubmitAction();
}
this.cancellationTokenSource = null;
}
private void TriggerFormChange(FormFieldChangedEventArgs _) private void TriggerFormChange(FormFieldChangedEventArgs _)
{ {
this.formChangeTimer.Stop(); this.formChangeTimer.Stop();
@ -286,16 +296,12 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase, IMe
this.isProcessing = true; this.isProcessing = true;
this.StateHasChanged(); this.StateHasChanged();
using (this.cancellationTokenSource = new()) // Use the selected provider to get the AI response.
{ // By awaiting this line, we wait for the entire
// Use the selected provider to get the AI response. // content to be streamed.
// By awaiting this line, we wait for the entire this.chatThread = await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(this.Logger), this.providerSettings.Model, this.lastUserPrompt, this.chatThread, this.cancellationTokenSource!.Token);
// 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;
this.isProcessing = false; this.isProcessing = false;
this.StateHasChanged(); this.StateHasChanged();

View File

@ -0,0 +1,124 @@
@attribute [Route(Routes.ASSISTANT_AI_STUDIO_I18N)]
@using AIStudio.Settings
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogI18N>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.NameSelecting())" @bind-Value="@this.selectedTargetLanguage" ValidateSelection="@this.ValidatingTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="Target language" AllowOther="@true" OtherValue="CommonLanguages.OTHER" @bind-OtherInput="@this.customTargetLanguage" ValidateOther="@this.ValidateCustomLanguage" LabelOther="Custom target language" SelectionUpdated="_ => this.OnChangedLanguage()" />
<ConfigurationSelect OptionDescription="Language plugin used for comparision" SelectedValue="@(() => this.selectedLanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(async void (id) => await this.OnLanguagePluginChanged(id))" OptionHelp="Select the language plugin used for comparision."/>
@if (this.isLoading)
{
<MudText Typo="Typo.body1" Class="mb-6">
The data is being loaded, please wait...
</MudText>
} else if (!this.isLoading && !string.IsNullOrWhiteSpace(this.loadingIssue))
{
<MudText Typo="Typo.body1" Class="mb-6">
While loading the I18N data, an issue occurred: @this.loadingIssue
</MudText>
}
else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue))
{
<MudText Typo="Typo.h6">
Added Content (@this.addedContent.Count entries)
</MudText>
<MudTable Items="@this.addedContent" Hover="@true" Filter="@this.FilterFunc" Class="border-dashed border rounded-lg mb-6">
<ToolBarContent>
<MudTextField @bind-Value="@this.searchString" Immediate="true" Placeholder="Search" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"/>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
</ColGroup>
<HeaderContent>
<MudTh>Key</MudTh>
<MudTh>Text</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<pre style="font-size: 0.8em;">
@context.Key
</pre>
</MudTd>
<MudTd>
@context.Value
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
<MudText Typo="Typo.h6">
Removed Content (@this.removedContent.Count entries)
</MudText>
<MudTable Items="@this.removedContent" Hover="@true" Filter="@this.FilterFunc" Class="border-dashed border rounded-lg mb-6">
<ToolBarContent>
<MudTextField @bind-Value="@this.searchString" Immediate="true" Placeholder="Search" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"/>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
</ColGroup>
<HeaderContent>
<MudTh>Key</MudTh>
<MudTh>Text</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<pre style="font-size: 0.8em;">
@context.Key
</pre>
</MudTd>
<MudTd>
@context.Value
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
@if (this.selectedTargetLanguage is CommonLanguages.EN_US)
{
<MudJustifiedText Typo="Typo.body1" Class="mb-6">
Please note: neither is a translation needed nor performed for English (USA). Anyway, you might want to generate the related Lua code.
</MudJustifiedText>
}
else
{
<ProviderSelection @bind-ProviderSettings="@this.providerSettings" ValidateProvider="@this.ValidatingProvider"/>
}
@if (this.localizedContent.Count > 0)
{
<hr style="width: 100%; border-width: 0.25ch;" class="mt-6 mb-6"/>
<MudText Typo="Typo.h6">
Localized Content (@this.localizedContent.Count entries of @this.NumTotalItems)
</MudText>
<MudTable Items="@this.localizedContent" Hover="@true" Filter="@this.FilterFunc" Class="border-dashed border rounded-lg mb-6">
<ToolBarContent>
<MudTextField @bind-Value="@this.searchString" Immediate="true" Placeholder="Search" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"/>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
</ColGroup>
<HeaderContent>
<MudTh>Key</MudTh>
<MudTh>Text</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<pre style="font-size: 0.8em;">
@context.Key
</pre>
</MudTd>
<MudTd>
@context.Value
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
}
}

View File

@ -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<SettingsDialogI18N>
{
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<IButtonData> 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<Task> 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<string, string> addedContent = [];
private Dictionary<string, string> removedContent = [];
private Dictionary<string, string> localizedContent = [];
private StringBuilder finalLuaCode = new();
#region Overrides of AssistantBase<SettingsDialogI18N>
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<string, string> 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);
}
}

View File

@ -14,6 +14,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
protected MessageBus MessageBus { get; init; } = null!; protected MessageBus MessageBus { get; init; } = null!;
[Inject] [Inject]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
private ILogger<MSGComponentBase> Logger { get; init; } = null!; private ILogger<MSGComponentBase> Logger { get; init; } = null!;
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;

View File

@ -68,11 +68,12 @@
<MudSelectItem Value="@inferenceProvider"> <MudSelectItem Value="@inferenceProvider">
@inferenceProvider.ToName() @inferenceProvider.ToName()
</MudSelectItem> </MudSelectItem>
} }
</MudSelect> </MudSelect>
@* ReSharper disable Asp.Entity *@
<MudJustifiedText Class="mb-3"> Please double-check if your model name matches the curl specifications provided by the inference provider. If it doesn't, you might get a <b>Not Found</b> error when trying to use the model. Here's a <MudLink Href="https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct?inference_api=true&inference_provider=novita&language=sh" Target="_blank">curl example</MudLink>.</MudJustifiedText> <MudJustifiedText Class="mb-3"> Please double-check if your model name matches the curl specifications provided by the inference provider. If it doesn't, you might get a <b>Not Found</b> error when trying to use the model. Here's a <MudLink Href="https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct?inference_api=true&inference_provider=novita&language=sh" Target="_blank">curl example</MudLink>.</MudJustifiedText>
} @* ReSharper restore Asp.Entity *@
}
<MudField FullWidth="true" Label="Model selection" Variant="Variant.Outlined" Class="mb-3"> <MudField FullWidth="true" Label="Model selection" Variant="Variant.Outlined" Class="mb-3">
<MudStack Row="@true" AlignItems="AlignItems.Center" StretchItems="StretchItems.End"> <MudStack Row="@true" AlignItems="AlignItems.Center" StretchItems="StretchItems.End">

View File

@ -1,5 +1,4 @@
@using AIStudio.Settings @using AIStudio.Settings
@using AIStudio.Settings.DataModel
@inherits SettingsDialogBase @inherits SettingsDialogBase
<MudDialog> <MudDialog>
<TitleContent> <TitleContent>

View File

@ -0,0 +1,28 @@
@using AIStudio.Settings
@inherits SettingsDialogBase
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Edit" Class="mr-2" />
@T("Assistant: Localization")
</MudText>
</TitleContent>
<DialogContent>
<MudPaper Class="pa-3 mb-8 border-dashed border rounded-lg">
<ConfigurationOption OptionDescription="Preselect localization options?" LabelOn="Localization options are preselected" LabelOff="No localization options are preselected" State="@(() => this.SettingsManager.ConfigurationData.I18N.PreselectOptions)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.I18N.PreselectOptions = updatedState)" OptionHelp="When enabled, you can preselect the localization options. This is might be useful when you prefer a specific language or LLM model."/>
<ConfigurationSelect OptionDescription="@T("Preselect the target language")" Disabled="@(() => !this.SettingsManager.ConfigurationData.I18N.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.I18N.PreselectedTargetLanguage)" Data="@ConfigurationSelectDataFactory.GetCommonLanguagesOptionalData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.I18N.PreselectedTargetLanguage = selectedValue)" OptionHelp="@T("Which target language should be preselected?")"/>
@if (this.SettingsManager.ConfigurationData.I18N.PreselectedTargetLanguage is CommonLanguages.OTHER)
{
<ConfigurationText OptionDescription="@T("Preselect another target language")" Disabled="@(() => !this.SettingsManager.ConfigurationData.I18N.PreselectOptions)" Icon="@Icons.Material.Filled.Translate" Text="@(() => this.SettingsManager.ConfigurationData.I18N.PreselectOtherLanguage)" TextUpdate="@(updatedText => this.SettingsManager.ConfigurationData.I18N.PreselectOtherLanguage = updatedText)"/>
}
<ConfigurationSelect OptionDescription="Language plugin used for comparision" SelectedValue="@(() => this.SettingsManager.ConfigurationData.I18N.PreselectedLanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.I18N.PreselectedLanguagePluginId = selectedValue)" OptionHelp="Select the language plugin used for comparision."/>
<ConfigurationProviderSelection Component="Components.I18N_ASSISTANT" Data="@this.availableLLMProviders" Disabled="@(() => !this.SettingsManager.ConfigurationData.I18N.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.I18N.PreselectedProvider)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.I18N.PreselectedProvider = selectedValue)"/>
</MudPaper>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Close" Variant="Variant.Filled">
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,5 @@
namespace AIStudio.Dialogs.Settings;
public partial class SettingsDialogI18N : SettingsDialogBase
{
}

View File

@ -61,10 +61,6 @@
<ProjectReference Include="..\SourceCodeRules\SourceCodeRules\SourceCodeRules.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/> <ProjectReference Include="..\SourceCodeRules\SourceCodeRules\SourceCodeRules.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Assistants\I18N\" />
</ItemGroup>
<!-- Read the meta data file --> <!-- Read the meta data file -->
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild"> <Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" /> <Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />

View File

@ -50,6 +50,16 @@
<AssistantBlock TSettings="SettingsDialogERIServer" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/> <AssistantBlock TSettings="SettingsDialogERIServer" Name="@T("ERI Server")" Description="@T("Generate an ERI server to integrate business systems.")" Icon="@Icons.Material.Filled.PrivateConnectivity" Link="@Routes.ASSISTANT_ERI"/>
} }
</MudStack> </MudStack>
@if (PreviewFeatures.PRE_PLUGINS_2025.IsEnabled(this.SettingsManager))
{
<MudText Typo="Typo.h4" Class="mb-2 mr-3 mt-6">
AI Studio Development
</MudText>
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="mb-3">
<AssistantBlock TSettings="SettingsDialogI18N" Name="@T("Localization")" Description="@T("Translate AI Studio text content into other languages")" Icon="@Icons.Material.Filled.Translate" Link="@Routes.ASSISTANT_AI_STUDIO_I18N"/>
</MudStack>
}
</InnerScrolling> </InnerScrolling>
</div> </div>

View File

@ -1,7 +1,5 @@
using AIStudio.Components; using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Pages; namespace AIStudio.Pages;
public partial class Supporters : MSGComponentBase; public partial class Supporters : MSGComponentBase;

View File

@ -26,5 +26,6 @@ public sealed partial class Routes
public const string ASSISTANT_JOB_POSTING = "/assistant/job-posting"; public const string ASSISTANT_JOB_POSTING = "/assistant/job-posting";
public const string ASSISTANT_BIAS = "/assistant/bias-of-the-day"; public const string ASSISTANT_BIAS = "/assistant/bias-of-the-day";
public const string ASSISTANT_ERI = "/assistant/eri"; public const string ASSISTANT_ERI = "/assistant/eri";
public const string ASSISTANT_AI_STUDIO_I18N = "/assistant/ai-studio/i18n";
// ReSharper restore InconsistentNaming // ReSharper restore InconsistentNaming
} }

View File

@ -100,4 +100,6 @@ public sealed class Data
public DataJobPostings JobPostings { get; init; } = new(); public DataJobPostings JobPostings { get; init; } = new();
public DataBiasOfTheDay BiasOfTheDay { get; init; } = new(); public DataBiasOfTheDay BiasOfTheDay { get; init; } = new();
public DataI18N I18N { get; init; } = new();
} }

View File

@ -0,0 +1,29 @@
namespace AIStudio.Settings.DataModel;
public class DataI18N
{
/// <summary>
/// Preselect any I18N options?
/// </summary>
public bool PreselectOptions { get; set; }
/// <summary>
/// Preselect a language plugin to where the new content should compare to?
/// </summary>
public Guid PreselectedLanguagePluginId { get; set; }
/// <summary>
/// Preselect the target language?
/// </summary>
public CommonLanguages PreselectedTargetLanguage { get; set; } = CommonLanguages.EN_GB;
/// <summary>
/// Preselect any other language?
/// </summary>
public string PreselectOtherLanguage { get; set; } = string.Empty;
/// <summary>
/// Which LLM provider should be preselected?
/// </summary>
public string PreselectedProvider { get; set; } = string.Empty;
}

View File

@ -1,6 +1,21 @@
namespace AIStudio.Tools; namespace AIStudio.Tools;
public readonly record struct ButtonData(string Text, string Icon, Color Color, string Tooltip, Func<Task> AsyncAction) : IButtonData public readonly record struct ButtonData(string Text, string Icon, Color Color, string Tooltip, Func<Task> AsyncAction, Func<bool>? DisabledActionParam) : IButtonData
{ {
public ButtonTypes Type => ButtonTypes.BUTTON; public ButtonTypes Type => ButtonTypes.BUTTON;
public Func<bool> DisabledAction
{
get
{
var data = this;
return () =>
{
if (data.DisabledActionParam is null)
return false;
return data.DisabledActionParam();
};
}
}
} }

View File

@ -20,6 +20,24 @@ public static class CommonLanguageExtensions
_ => "Other", _ => "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 public static string PromptSummarizing(this CommonLanguages language, string customLanguage) => language switch
{ {
CommonLanguages.AS_IS => "Do not change the language of the text.", CommonLanguages.AS_IS => "Do not change the language of the text.",

View File

@ -19,6 +19,10 @@ public enum Components
BIAS_DAY_ASSISTANT, BIAS_DAY_ASSISTANT,
ERI_ASSISTANT, ERI_ASSISTANT,
// ReSharper disable InconsistentNaming
I18N_ASSISTANT,
// ReSharper restore InconsistentNaming
CHAT, CHAT,
APP_SETTINGS, APP_SETTINGS,

View File

@ -10,8 +10,11 @@ public static class ComponentsExtensions
public static bool AllowSendTo(this Components component) => component switch public static bool AllowSendTo(this Components component) => component switch
{ {
Components.NONE => false, Components.NONE => false,
Components.ERI_ASSISTANT => false, Components.ERI_ASSISTANT => false,
Components.BIAS_DAY_ASSISTANT => false, Components.BIAS_DAY_ASSISTANT => false,
Components.I18N_ASSISTANT => false,
Components.APP_SETTINGS => false, Components.APP_SETTINGS => false,
Components.AGENT_TEXT_CONTENT_CLEANER => false, Components.AGENT_TEXT_CONTENT_CLEANER => false,
@ -36,6 +39,7 @@ public static class ComponentsExtensions
Components.MY_TASKS_ASSISTANT => "My Tasks Assistant", Components.MY_TASKS_ASSISTANT => "My Tasks Assistant",
Components.JOB_POSTING_ASSISTANT => "Job Posting Assistant", Components.JOB_POSTING_ASSISTANT => "Job Posting Assistant",
Components.ERI_ASSISTANT => "ERI Server", Components.ERI_ASSISTANT => "ERI Server",
Components.I18N_ASSISTANT => "Localization Assistant",
Components.CHAT => "New Chat", 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.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.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.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, Components.CHAT => settingsManager.ConfigurationData.Chat.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Chat.PreselectedProvider) : default,

View File

@ -29,4 +29,9 @@ public interface ILanguagePlugin
/// Gets the name of the language. /// Gets the name of the language.
/// </summary> /// </summary>
public string LangName { get; } public string LangName { get; }
/// <summary>
/// Get all keys and texts from the language plugin.
/// </summary>
public IReadOnlyDictionary<string, string> Content { get; }
} }

View File

@ -22,5 +22,7 @@ public sealed class NoPluginLanguage : PluginBase, ILanguagePlugin
public string LangName => string.Empty; public string LangName => string.Empty;
public IReadOnlyDictionary<string, string> Content => new Dictionary<string, string>();
#endregion #endregion
} }

View File

@ -101,16 +101,18 @@ public static partial class PluginFactory
} }
} }
private static async Task<PluginBase> Load(string pluginPath, string code, CancellationToken cancellationToken = default) public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default)
{ {
if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState) if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState)
return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}"); return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}");
var state = LuaState.Create(); var state = LuaState.Create();
if (!string.IsNullOrWhiteSpace(pluginPath))
// Add the module loader so that the plugin can load other Lua modules: {
state.ModuleLoader = new PluginLoader(pluginPath); // Add the module loader so that the plugin can load other Lua modules:
state.ModuleLoader = new PluginLoader(pluginPath);
}
// Add some useful libraries: // Add some useful libraries:
state.OpenModuleLibrary(); state.OpenModuleLibrary();
state.OpenStringLibrary(); state.OpenStringLibrary();
@ -141,7 +143,7 @@ public static partial class PluginFactory
if(type is PluginType.NONE) if(type is PluginType.NONE)
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}"); return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
var isInternal = pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase); var isInternal = !string.IsNullOrWhiteSpace(pluginPath) && pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
return type switch return type switch
{ {
PluginType.LANGUAGE => new PluginLanguage(isInternal, state, type), PluginType.LANGUAGE => new PluginLanguage(isInternal, state, type),

View File

@ -165,6 +165,9 @@ public sealed class PluginLanguage : PluginBase, ILanguagePlugin
/// <inheritdoc /> /// <inheritdoc />
public string LangName => this.langName; public string LangName => this.langName;
/// <inheritdoc />
public IReadOnlyDictionary<string, string> Content => this.content.AsReadOnly();
#endregion #endregion
} }

View File

@ -1,5 +1,6 @@
# v0.9.41, build 216 (2025-0x-xx xx:xx UTC) # 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 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. - 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 hot reloading of the plugin system to prevent overlapping reloads.
- Improved the app behavior when the user system was waked up from sleep mode. - Improved the app behavior when the user system was waked up from sleep mode.

View File

@ -0,0 +1,41 @@
using System.Text;
namespace SharedTools;
public static class LuaTable
{
public static string Create(ref StringBuilder sb, string tableVariableName, IReadOnlyDictionary<string, string> keyValuePairs, IReadOnlyDictionary<string, string>? 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();
}
}

View File

@ -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("\"", "\\\"");
}
}