Fixed plugin values which depended on the region settings of the computer (#1194)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-12 08:57:50 +01:00
1 parent 65d50de033
commit a1b493cb13
20 files changed
+395 -135

No files matched your search

@@ -539,7 +539,11 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
{
Id = Guid.NewGuid().ToString(),
Num = this.SettingsManager.ConfigurationData.NextDocumentAnalysisPolicyNum++,
PolicyName = string.Format(T("Policy {0}"), DateTimeOffset.UtcNow),
//
// The date follows the language of the app, like the rest of the name. With seconds, so that two
// policies added right after each other do not get the same name, which has to be unique:
//
PolicyName = string.Format(T("Policy {0}"), DateTime.Now.ToString("G", Tools.PluginSystem.I18N.I.Culture)),
});
await this.SettingsManager.StoreSettings();
@@ -8791,6 +8791,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Yo
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "The ERI server '{0}' writes into a folder which does not exist on this computer."
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- How many matches do you want at most per query?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "How many matches do you want at most per query?"
@@ -8989,27 +9010,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = "
-- Embeddings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Embeddings"
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Folder"
@@ -204,7 +204,7 @@ Launchers with an own icon or extra Lua code keep the plugin code editor and the
#### Supported types (matching the Blazor UI components):
- `TEXT_AREA`: user input field based on `MudTextField`; requires `Name`, `Label`, and may include `HelperText`, `HelperTextOnFocus`, `Adornment`, `AdornmentIcon`, `AdornmentText`, `AdornmentColor`, `Counter`, `MaxLength`, `IsImmediate`, `UserPrompt`, `PrefillText`, `IsSingleLine`, `ReadOnly`, `Class`, `Style`.
- `DROPDOWN`: selects between variants; `Props` must include `Name`, `Label`, `Default`, `Items`, and optionally `ValueType` plus `UserPrompt`.
- `DROPDOWN`: selects between variants; `Props` must include `Name`, `Label`, `Default`, `Items`, and optionally `UserPrompt`.
- `BUTTON`: invokes a Lua callback; `Props` must include `Name`, `Text`, `Action`, and may include `IsIconButton`, `Variant`, `Color`, `IsFullWidth`, `Size`, `StartIcon`, `EndIcon`, `IconColor`, `IconSize`, `Class`, `Style`. Use this for stateless actions, including icon-only action buttons.
- `BUTTON_GROUP`: groups multiple `BUTTON` children in a `MudButtonGroup`;`Props` must include `Name`, `Children` must contain only `BUTTON` components and `Props` may include `Variant`, `Color`, `Size`, `OverrideStyles`, `Vertical`, `DropShadow`, `Class`, `Style`.
- `LAYOUT_GRID`: renders a `MudGrid`; `Children` must contain only `LAYOUT_ITEM` components and `Props` may include `Justify`, `Spacing`, `Class`, `Style`.
@@ -231,7 +231,7 @@ Images referenced via the `plugin://` scheme must exist in the plugin directory
| Component | Required Props | Optional Props | Renders |
|----------------------------|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| `TEXT_AREA` | `Name`, `Label` | `HelperText`, `HelperTextOnFocus`, `Adornment`, `AdornmentIcon`, `AdornmentText`, `AdornmentColor`, `Counter`, `MaxLength`, `IsImmediate`, `UserPrompt`, `PrefillText`, `IsSingleLine`, `ReadOnly`, `Class`, `Style` | [MudTextField](https://www.mudblazor.com/components/textfield) |
| `DROPDOWN` | `Name`, `Label`, `Default`, `Items` | `IsMultiselect`, `HasSelectAll`, `SelectAllText`, `HelperText`, `OpenIcon`, `CloseIcon`, `IconColor`, `IconPositon`, `Variant`, `ValueType`, `UserPrompt` | [MudSelect](https://www.mudblazor.com/components/select) |
| `DROPDOWN` | `Name`, `Label`, `Default`, `Items` | `IsMultiselect`, `HasSelectAll`, `SelectAllText`, `HelperText`, `OpenIcon`, `CloseIcon`, `IconColor`, `IconPositon`, `Variant`, `UserPrompt` | [MudSelect](https://www.mudblazor.com/components/select) |
| `BUTTON` | `Name`, `Text`, `Action` | `IsIconButton`, `Variant`, `Color`, `IsFullWidth`, `Size`, `StartIcon`, `EndIcon`, `IconColor`, `IconSize`, `Class`, `Style` | [MudButton](https://www.mudblazor.com/components/button) / [MudIconButton](https://www.mudblazor.com/components/button#icon-button) |
| `BUTTON_GROUP` | `Name`, `Children` | `Variant`, `Color`, `Size`, `OverrideStyles`, `Vertical`, `DropShadow`, `Class`, `Style` | [MudButton](https://www.mudblazor.com/components/button) / [MudIconButton](https://www.mudblazor.com/components/button#icon-button) |
| `SWITCH` | `Name`, `Label`, `Value` | `OnChanged`, `Disabled`, `UserPrompt`, `LabelOn`, `LabelOff`, `LabelPlacement`, `Icon`, `IconColor`, `CheckedColor`, `UncheckedColor`, `Class`, `Style` | [MudSwitch](https://www.mudblazor.com/components/switch) |
@@ -320,7 +320,7 @@ An unknown name shows no icon.
- `Items`: array of dropdown item tables with the same shape as `Default`.
- Optional props:
- `UserPrompt`: prompt context text for this field.
- `ValueType`: one of `string`, `int`, `double`, `bool`; currently the dropdown values exposed to prompt building and button actions are handled as the configured item `Value`s, with typical usage being `string`.
- `ValueType`: has no effect and is accepted only so that older plugins keep loading; leave it out. The selected values always reach prompt building and button actions as text, i.e., as the `Value` of their items.
- `IsMultiselect`: defaults to `false`; when `true`, the component allows selecting multiple items.
- `HasSelectAll`: defaults to `false`; enables MudBlazor's select-all behavior for multiselect dropdowns.
- `SelectAllText`: custom label for the select-all action in multiselect mode.
@@ -349,7 +349,6 @@ An unknown name shows no icon.
["Name"] = "targetCountries",
["Label"] = "Target countries",
["UserPrompt"] = "Use the selected countries in your answer.",
["ValueType"] = "string",
["IsMultiselect"] = true,
["HasSelectAll"] = true,
["SelectAllText"] = "Select all countries",
@@ -1195,6 +1194,7 @@ Use these when you need timestamps inside Lua.
- `DateTime(format)` returns a table with date/time parts plus a formatted string.
- `format` is optional; default is `yyyy-MM-dd HH:mm:ss` (ISO 8601-like).
- `formatted` contains the date in your desired format (e.g. `dd.MM.yyyy HH:mm`) or the default.
- `formatted` looks the same on every computer, whatever its region settings: `:` and `/` stay as written, the calendar is the Gregorian one, and the names of months and days are English, e.g., `MMMM` gives `March`.
- Members: `year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`, `formatted`.
- `Timestamp()` returns a UTC timestamp in ISO-8601 format (`O` / round-trip), e.g. `2026-03-02T21:15:30.1234567Z`.
@@ -138,7 +138,6 @@ ASSISTANT = {
["IconColor"] = "<Dark|Error|Info|Inherit|Primary|Secondary|Success|Surface|Tertiary|Transparent|Warning>",
["IconPositon"] = "<Start|End>",
["Variant"] = "<Text|Filled|Outlined>",
["ValueType"] = "<string|int|bool>", -- required
["Default"] = { ["Value"] = "<internal data>", ["Display"] = "<user readable representation>" }, -- required
["Items"] = {
{ ["Value"] = "<internal data>", ["Display"] = "<user readable representation>" },
@@ -8793,6 +8793,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Ih
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "Der ERI-Server „{0}“ schreibt in einen Ordner, der auf diesem Rechner nicht existiert."
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Für alle Benutzer denselben Benutzernamen und dasselbe Passwort verwenden"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Modus für den Benutzernamen und das Passwort"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "Wie soll AI Studio die Konfiguration von Benutzername und Passwort für die ERI-v1-Datenquelle „{0}“ exportieren?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "Vom Benutzer verwaltete Anmeldedaten (Benutzername und Passwort)"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Exportieren"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Den Benutzernamen jedes Benutzers aus dem Betriebssystem auslesen und ein Passwort teilen"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Abbrechen"
-- How many matches do you want at most per query?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "Wie viele Treffer möchten Sie maximal pro Abfrage erhalten?"
@@ -8991,27 +9012,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = "
-- Embeddings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Einbettungen"
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Für alle Benutzer denselben Benutzernamen und dasselbe Passwort verwenden"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Modus für den Benutzernamen und das Passwort"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "Wie soll AI Studio die Konfiguration von Benutzername und Passwort für die ERI-v1-Datenquelle „{0}“ exportieren?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "Vom Benutzer verwaltete Anmeldedaten (Benutzername und Passwort)"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Exportieren"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Den Benutzernamen jedes Benutzers aus dem Betriebssystem auslesen und ein Passwort teilen"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Abbrechen"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Ordner"
@@ -8793,6 +8793,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T3980336073"] = "Yo
-- The ERI server '{0}' writes into a folder which does not exist on this computer.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATARESTOREREPORTDIALOG::T472632172"] = "The ERI server '{0}' writes into a folder which does not exist on this computer."
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- How many matches do you want at most per query?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1DIALOG::T1827669611"] = "How many matches do you want at most per query?"
@@ -8991,27 +9012,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T742006305"] = "
-- Embeddings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERI_V1INFODIALOG::T951463987"] = "Embeddings"
-- Use the same username and password for all users
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1769874785"] = "Use the same username and password for all users"
-- Username and password mode
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T1787063064"] = "Username and password mode"
-- How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3081234668"] = "How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"
-- User-managed username and password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T365340972"] = "User-managed username and password"
-- Export
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T3898821075"] = "Export"
-- Read each user's username from the operating system and share one password
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T76405695"] = "Read each user's username from the operating system and share one password"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Folder"
@@ -1264,16 +1264,30 @@ public static partial class ManagedConfiguration
System.Collections.IDictionary dictionary => string.Join(";", dictionary.Keys
.Cast<object>()
.OrderBy(key => key.ToString(), StringComparer.Ordinal)
.Select(key => $"{key}:{dictionary[key]}")),
.OrderBy(SerializeManagedItem, StringComparer.Ordinal)
.Select(key => $"{SerializeManagedItem(key)}:{SerializeManagedItem(dictionary[key])}")),
System.Collections.IEnumerable enumerable => string.Join(";", enumerable
.Cast<object>()
.Select(item => item.ToString() ?? string.Empty)
.Select(SerializeManagedItem)
.Order(StringComparer.Ordinal)),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty,
};
/// <summary>
/// Writes a key, a value, or an item of a collection the same way on every computer, as the scalars above.
/// </summary>
/// <remarks>
/// For the types of today's settings, i.e., whole numbers, enums, and texts, this is the form they were stored in
/// before; a number with a fraction or a date would otherwise follow the region settings of the computer.
/// </remarks>
private static string SerializeManagedItem(object? item) => item switch
{
null => string.Empty,
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => item.ToString() ?? string.Empty,
};
private static Dictionary<string, string> CloneStringDictionary(IDictionary<string, string> values) => new(values, StringComparer.Ordinal);
}
@@ -1,3 +1,5 @@
using System.Globalization;
using AIStudio.Tools.PluginSystem.Assistants.Icons;
namespace AIStudio.Tools.PluginSystem.Assistants.DataModel;
@@ -9,26 +11,58 @@ internal static class AssistantComponentPropHelper
/// </summary>
private const string LUCIDE_PREFIX = "lucide:";
/// <summary>
/// Reads a prop as text. A number, which Lua hands over as a double, becomes the same text on every computer,
/// the one Lua itself writes: 12.5 stays "12.5", also where the region settings use a decimal comma.
/// </summary>
public static string ReadString(Dictionary<string, object> props, string key)
{
if (props.TryGetValue(key, out var value))
return value.ToString() ?? string.Empty;
if (!props.TryGetValue(key, out var value))
return string.Empty;
return string.Empty;
if (value is IFormattable formattable)
return formattable.ToString(null, CultureInfo.InvariantCulture);
return value.ToString() ?? string.Empty;
}
public static void WriteString(Dictionary<string, object> props, string key, string value) => props[key] = value;
public static int ReadInt(Dictionary<string, object> props, string key, int fallback = 0)
{
return props.TryGetValue(key, out var value) && int.TryParse(value.ToString(), out var i) ? i : fallback;
}
public static int ReadInt(Dictionary<string, object> props, string key, int fallback = 0) => TryReadInt(props, key, out var number) ? number : fallback;
public static void WriteInt(Dictionary<string, object> props, string key, int value) => props[key] = value;
public static int? ReadNullableInt(Dictionary<string, object> props, string key)
public static int? ReadNullableInt(Dictionary<string, object> props, string key) => TryReadInt(props, key, out var number) ? number : null;
/// <summary>
/// Reads a whole number, independent of the region settings of the computer.
/// </summary>
/// <remarks>
/// Lua hands over every number as a double, and the setters of the components store an int. A plugin may also
/// write the number as text. A number with a fraction, or one beyond the range of an int, counts as missing.
/// </remarks>
private static bool TryReadInt(Dictionary<string, object> props, string key, out int number)
{
return props.TryGetValue(key, out var value) && int.TryParse(value.ToString(), out var i) ? i : null;
number = 0;
if (!props.TryGetValue(key, out var value))
return false;
switch (value)
{
case int whole:
number = whole;
return true;
case double real when double.IsInteger(real) && real is >= int.MinValue and <= int.MaxValue:
number = (int)real;
return true;
case string text:
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out number);
default:
return false;
}
}
public static void WriteNullableInt(Dictionary<string, object> props, string key, int? value)
@@ -32,14 +32,6 @@ internal sealed class AssistantDropdown : StatefulAssistantComponentBase
set => this.Props[nameof(this.Items)] = value;
}
public string ValueType
{
get => this.Props.TryGetValue(nameof(this.ValueType), out var v)
? v.ToString() ?? "string"
: "string";
set => this.Props[nameof(this.ValueType)] = value;
}
public bool IsMultiselect
{
get => AssistantComponentPropHelper.ReadBool(this.Props, nameof(this.IsMultiselect));
@@ -141,28 +133,6 @@ internal sealed class AssistantDropdown : StatefulAssistantComponentBase
return [this.Default, .. this.Items];
}
public IEnumerable<object> GetParsedDropdownValues()
{
foreach (var item in this.Items)
{
switch (this.ValueType.ToLowerInvariant())
{
case "int":
if (int.TryParse(item.Value, out var i)) yield return i;
break;
case "double":
if (double.TryParse(item.Value, out var d)) yield return d;
break;
case "bool":
if (bool.TryParse(item.Value, out var b)) yield return b;
break;
default:
yield return item.Value;
break;
}
}
}
public string Class
{
get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Class));
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using AIStudio.Assistants;
using AIStudio.Tools.PluginSystem.Assistants.DataModel;
using AIStudio.Tools.PluginSystem.Assistants.DataModel.Layout;
@@ -856,21 +857,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
this.State.Environment["DateTime"] = new LuaFunction((context, _) =>
{
var format = context.ArgumentCount > 0 ? context.GetArgument<string>(0) : "yyyy-MM-dd HH:mm:ss";
var now = DateTime.Now;
var formattedDate = now.ToString(format);
var table = new LuaTable
{
["year"] = now.Year,
["month"] = now.Month,
["day"] = now.Day,
["hour"] = now.Hour,
["minute"] = now.Minute,
["second"] = now.Second,
["millisecond"] = now.Millisecond,
["formatted"] = formattedDate,
};
return new(context.Return(table));
return new(context.Return(CreateDateTimeTable(DateTime.Now, format)));
});
this.State.Environment["Timestamp"] = new LuaFunction((context, _) =>
@@ -889,6 +876,29 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
});
}
/// <summary>
/// Builds the table which the Lua function DateTime returns for the given moment.
/// </summary>
/// <remarks>
/// The formatted text is the same on every computer. With the culture of the computer, the separators, the
/// calendar, and the names of months and days would follow its region settings, e.g., the year of the Buddhist
/// era in Thailand.
/// </remarks>
/// <param name="moment">The moment to describe.</param>
/// <param name="format">The .NET format of the formatted text.</param>
/// <returns>The parts of the moment and the formatted text.</returns>
internal static LuaTable CreateDateTimeTable(DateTime moment, string format) => new()
{
["year"] = moment.Year,
["month"] = moment.Month,
["day"] = moment.Day,
["hour"] = moment.Hour,
["minute"] = moment.Minute,
["second"] = moment.Second,
["millisecond"] = moment.Millisecond,
["formatted"] = moment.ToString(format, CultureInfo.InvariantCulture)
};
private static void InitializeState(IEnumerable<IAssistantComponent> components, AssistantState state)
{
foreach (var component in components)
@@ -1,4 +1,6 @@
// ReSharper disable MemberCanBePrivate.Global
using System.Globalization;
namespace AIStudio.Tools.PluginSystem;
/// <summary>
@@ -46,9 +48,9 @@ public readonly record struct PluginVersion(int Major, int Minor, int Patch) : I
if (segments.Length != 3)
throw new FormatException("The input string must be in the format 'major.minor.patch'.");
var major = int.Parse(segments[0]);
var minor = int.Parse(segments[1]);
var patch = int.Parse(segments[2]);
var major = int.Parse(segments[0], NumberStyles.Integer, CultureInfo.InvariantCulture);
var minor = int.Parse(segments[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
var patch = int.Parse(segments[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
if(major < 0 || minor < 0 || patch < 0)
throw new FormatException("The major, minor, and patch numbers must be greater than or equal to 0.");
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
@@ -208,7 +209,11 @@ internal sealed class SearXNGSearchClient
private static string ReadNodeString(JsonNode? node) => node is null ? string.Empty : node.ToString().Trim();
private static bool TryGetScore(JsonObject result, out double score)
/// <remarks>
/// An instance may also send the score as text. It comes in the notation of JSON, so the region
/// settings of the computer must not read it: with a decimal comma, "0.75" would become 75.
/// </remarks>
internal static bool TryGetScore(JsonObject result, out double score)
{
score = double.MinValue;
if (!result.TryGetPropertyValue("score", out var scoreNode) || scoreNode is null)
@@ -219,7 +224,7 @@ internal sealed class SearXNGSearchClient
JsonValue value when value.TryGetValue<double>(out var doubleScore) => ReturnScore(doubleScore, out score),
JsonValue value when value.TryGetValue<decimal>(out var decimalScore) => ReturnScore((double)decimalScore, out score),
JsonValue value when value.TryGetValue<int>(out var intScore) => ReturnScore(intScore, out score),
_ => double.TryParse(scoreNode.ToString(), out var parsedScore) && ReturnScore(parsedScore, out score),
_ => double.TryParse(scoreNode.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedScore) && ReturnScore(parsedScore, out score),
};
}
@@ -1,3 +1,5 @@
using System.Globalization;
namespace AIStudio.Tools.ToolCallingSystem;
internal static class ToolSettingsValueParser
@@ -7,7 +9,7 @@ internal static class ToolSettingsValueParser
if (!settingsValues.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
return null;
return int.TryParse(value, out var parsedValue) && parsedValue > 0 ? parsedValue : null;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue > 0 ? parsedValue : null;
}
public static bool TryReadOptionalPositiveInt(
@@ -23,7 +25,7 @@ internal static class ToolSettingsValueParser
if (!settingsValues.TryGetValue(key, out var rawValue) || string.IsNullOrWhiteSpace(rawValue))
return true;
if (int.TryParse(rawValue, out var parsedValue) && parsedValue > 0)
if (int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue > 0)
{
value = parsedValue;
return true;
@@ -2,3 +2,4 @@
- Added the BeyondFacts Lab as a prototype. It helps researchers prepare step by step for a challenging appearance, such as a panel discussion, an interview, or a hearing. An introduction explains how to work with the lab, and a chat tells you whether the lab fits your task. Each event gets a preparation of its own, which leads you through seven steps, from the details of the event to what you take away, and shows you which key details are still missing. In each step, a chat guides you with questions, based on your details and a guide on science in public debates. You can add material which every step takes into account, such as papers, the recording of an interview, or a poster. Your preparations are part of your data backups. To try it, open the app settings, allow preview features down to prototype, and enable the BeyondFacts Lab. Thanks to Lara Wüster from the Technical University of Munich (TUM) for the concept and the content of the lab.
- Added Lucide icons to assistant plugins: name an icon as `lucide:` followed by its name on lucide.dev, such as `lucide:house`. The names of MudBlazor icons keep working, so existing plugins need no change.
- Changed the icons of AI Studio to Lucide, an open-source icon set. All icons now share one style, and each feature shows the same icon wherever you meet it.
- Fixed assistant plugins which wrote numbers and dates according to the region settings of your computer. Plugins now show them the same way on every computer: a decimal number such as `12.5` keeps its point, and the `DateTime` helper writes the names of months in English.
+6 -1
View File
@@ -12,7 +12,12 @@ public static class LuaTable
// Add the UI_TEXT_CONTENT table:
//
sb.AppendLineLF($$"""{{tableVariableName}} = {}""");
foreach (var kvp in keyValuePairs.OrderBy(x => x.Key))
//
// Ordinal, so that every computer writes the texts in the same order. A comparison by culture would
// depend on the region settings of the computer, e.g., where it sorts the underscore:
//
foreach (var kvp in keyValuePairs.OrderBy(x => x.Key, StringComparer.Ordinal))
{
if (cancellationToken.IsCancellationRequested)
return sb.ToString();
@@ -0,0 +1,48 @@
using System.Globalization;
using AIStudio.Settings;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks that the stored form of a managed setting does not depend on the region settings of the computer.
/// </summary>
/// <remarks>
/// The settings keep the last value an organization applied as an editable default in this form, and compare it with
/// the current value to see whether the user changed it. A form which followed the culture would make the value look
/// changed once the computer, or its region settings, changed. All cases run under de-DE, which writes a decimal comma.
/// </remarks>
[TestFixture]
[SetCulture("de-DE")]
public sealed class ManagedValueCultureTests
{
[Test]
public void TheCultureWritesADecimalComma()
{
Assert.That(1.5.ToString(CultureInfo.CurrentCulture), Is.EqualTo("1,5"), "Without a decimal comma, the other cases check nothing.");
}
[Test]
public void TheItemsOfAListAreWrittenTheSameOnEveryComputer()
{
Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new List<double> { 2.25, 1.5 }), Is.EqualTo("1.5;2.25"));
}
[Test]
public void TheKeysAndValuesOfATableAreWrittenTheSameOnEveryComputer()
{
var table = new Dictionary<int, double> { [2] = 0.5, [10] = 1.25 };
Assert.That(ManagedConfiguration.SerializeManagedScalarValue(table), Is.EqualTo("10:1.25;2:0.5"));
}
[Test]
public void WholeNumbersKeepTheFormTheyWereStoredInBefore()
{
using (Assert.EnterMultipleScope())
{
Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new List<int> { 12, 3 }), Is.EqualTo("12;3"));
Assert.That(ManagedConfiguration.SerializeManagedScalarValue(new Dictionary<int, string> { [1] = "one" }), Is.EqualTo("1:one"));
}
}
}
+26
View File
@@ -84,6 +84,32 @@ public sealed class LuaTableTests
""".ReplaceLineEndings("\n")));
}
/// <summary>
/// The order of the texts must not depend on the region settings of the computer which writes them.
/// </summary>
/// <remarks>
/// A comparison by culture sorts the underscore before the letters, the ordinal one after them. The
/// language plugins were written in the first order before, so this test fails with it.
/// </remarks>
[Test]
[SetCulture("de-DE")]
public void TheTextsAreOrderedOrdinallyWhateverTheCulture()
{
var code = Create(new Dictionary<string, string>
{
["UI_TEXT_CONTENT.AISTUDIO.DIALOGS.A_B.T1"] = "With an underscore",
["UI_TEXT_CONTENT.AISTUDIO.DIALOGS.AB.T1"] = "Without an underscore"
});
var withoutUnderscore = code.IndexOf("AISTUDIO::DIALOGS::AB::T1", StringComparison.Ordinal);
var withUnderscore = code.IndexOf("AISTUDIO::DIALOGS::A_B::T1", StringComparison.Ordinal);
using (Assert.EnterMultipleScope())
{
Assert.That(withoutUnderscore, Is.GreaterThanOrEqualTo(0), code);
Assert.That(withUnderscore, Is.GreaterThan(withoutUnderscore), code);
}
}
private static string Create(IReadOnlyDictionary<string, string> texts, IReadOnlyDictionary<string, string>? comments = null)
{
// The Lua runtime has a type of the same name, which stands for a table in Lua:
@@ -0,0 +1,98 @@
using System.Globalization;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.PluginSystem.Assistants.DataModel;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that assistant plugins read numbers and write dates the same way on every computer.
/// </summary>
/// <remarks>
/// The app sets no culture of its own, so the current culture is the one of the operating system. Each case runs
/// under a culture which writes differently than the invariant one: de-DE with a decimal comma and German names of
/// months, sv-SE with a minus sign of its own (U+2212), fi-FI with a period between hours and minutes, and th-TH
/// with the years of the Buddhist era. Each case first checks that its culture really writes differently, so that
/// a system without the data of the cultures fails instead of passing.
/// </remarks>
[TestFixture]
public sealed class AssistantPluginCultureTests
{
private static readonly DateTime MOMENT = new(2026, 3, 11, 14, 3, 22, 500);
[TestCase("de-DE", 12.5, "12.5")]
[TestCase("sv-SE", -3.5, "-3.5")]
public void ANumberFromLuaBecomesTheSameTextOnEveryComputer(string culture, double number, string expected)
{
InCulture(culture, () =>
{
var props = new Dictionary<string, object> { ["PrefillText"] = number };
using (Assert.EnterMultipleScope())
{
Assert.That(number.ToString(CultureInfo.CurrentCulture), Is.Not.EqualTo(expected), "The culture must write the number differently, or this case checks nothing.");
Assert.That(AssistantComponentPropHelper.ReadString(props, "PrefillText"), Is.EqualTo(expected));
}
});
}
[TestCase("de-DE")]
[TestCase("sv-SE")]
public void AWholeNumberIsReadOnEveryComputer(string culture)
{
InCulture(culture, () =>
{
var props = new Dictionary<string, object>
{
["FromLua"] = -3.0,
["FromASetter"] = 7,
["AsText"] = "-12",
["WithAFraction"] = 6.5,
["TooLarge"] = 1e15
};
using (Assert.EnterMultipleScope())
{
Assert.That(AssistantComponentPropHelper.ReadInt(props, "FromLua"), Is.EqualTo(-3));
Assert.That(AssistantComponentPropHelper.ReadInt(props, "FromASetter"), Is.EqualTo(7));
Assert.That(AssistantComponentPropHelper.ReadInt(props, "AsText"), Is.EqualTo(-12));
Assert.That(AssistantComponentPropHelper.ReadInt(props, "WithAFraction", fallback: 42), Is.EqualTo(42));
Assert.That(AssistantComponentPropHelper.ReadNullableInt(props, "TooLarge"), Is.Null);
Assert.That(AssistantComponentPropHelper.ReadNullableInt(props, "Missing"), Is.Null);
}
});
}
[TestCase("fi-FI", "yyyy-MM-dd HH:mm:ss", "2026-03-11 14:03:22")]
[TestCase("th-TH", "yyyy-MM-dd HH:mm:ss", "2026-03-11 14:03:22")]
[TestCase("de-DE", "dd. MMMM yyyy", "11. March 2026")]
public void TheDateTimeHelperFormatsTheSameOnEveryComputer(string culture, string format, string expected)
{
InCulture(culture, () =>
{
var table = PluginAssistants.CreateDateTimeTable(MOMENT, format);
using (Assert.EnterMultipleScope())
{
Assert.That(MOMENT.ToString(format, CultureInfo.CurrentCulture), Is.Not.EqualTo(expected), "The culture must write the moment differently, or this case checks nothing.");
Assert.That(table["formatted"].Read<string>(), Is.EqualTo(expected));
Assert.That(table["year"].Read<double>(), Is.EqualTo(2026));
Assert.That(table["millisecond"].Read<double>(), Is.EqualTo(500));
}
});
}
private static void InCulture(string name, Action check)
{
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name);
check();
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
}
}
@@ -24,9 +24,12 @@ namespace AIStudio.Tests.Tools.PluginSystem;
/// tables are compared value by value. A parser which read a single string differently than Lua
/// would hand the import something the configuration plugin of the same snippet never contains.<br/><br/>
/// Nothing here asserts on the wording of an issue: I18N is process-wide state without a reset, so
/// an Init somewhere else would decide whether these pass.
/// an Init somewhere else would decide whether these pass.<br/><br/>
/// All cases run under de-DE, which writes a decimal comma: an export which wrote a number with the
/// region settings of the computer would fail the comparison with Lua here.
/// </remarks>
[TestFixture]
[SetCulture("de-DE")]
public sealed class ConfigurationSnippetParserTests
{
/// <summary>
@@ -0,0 +1,39 @@
using System.Globalization;
using System.Text.Json.Nodes;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.SearXNG;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks that the score of a SearXNG result is read the same way on every computer.
/// </summary>
/// <remarks>
/// The score decides the order of the results. An instance may send it as a number or as text, both in the notation
/// of JSON. All cases run under de-DE, where the period separates thousands: read with that culture, "0.75" was 75.
/// </remarks>
[TestFixture]
[SetCulture("de-DE")]
public sealed class SearXNGScoreTests
{
[Test]
public void TheCultureReadsThePeriodAsAThousandsSeparator()
{
Assert.That(double.Parse("0.75", CultureInfo.CurrentCulture), Is.EqualTo(75), "Without that, the other cases check nothing.");
}
[TestCase("""{ "score": 0.75 }""")]
[TestCase("""{ "score": "0.75" }""")]
public void TheScoreIsReadTheSameOnEveryComputer(string json)
{
var result = JsonNode.Parse(json)!.AsObject();
var found = SearXNGSearchClient.TryGetScore(result, out var score);
using (Assert.EnterMultipleScope())
{
Assert.That(found, Is.True);
Assert.That(score, Is.EqualTo(0.75));
}
}
}