From 2ccb72c77db5b60c5e243cac27d1b47409cd44df Mon Sep 17 00:00:00 2001 From: krut_ni Date: Tue, 10 Mar 2026 15:43:40 +0100 Subject: [PATCH] buttons now support lua functions as actions, allowing plugin devs to exectute them on their own --- .../Assistants/Dynamic/AssistantDynamic.razor | 21 ++++ .../Dynamic/AssistantDynamic.razor.cs | 111 ++++++++++++++++++ .../Plugins/assistants/README.md | 64 ++++++++++ .../Plugins/assistants/plugin.lua | 32 +++++ .../Assistants/DataModel/AssistantButton.cs | 66 ++++++++++- .../DataModel/AssistantComponentPropHelper.cs | 14 +++ .../DataModel/ComponentPropSpecs.cs | 5 +- .../Assistants/PluginAssistants.cs | 43 ++++++- 8 files changed, 347 insertions(+), 9 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor index 10689bca..726ce248 100644 --- a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor +++ b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor @@ -85,6 +85,27 @@ Style="@assistantDropdown.Style"/> } break; + case AssistantComponentType.BUTTON: + if (component is AssistantButton assistantButton) + { + var button = assistantButton; +
+ + @button.Text +
+ } + break; case AssistantComponentType.PROVIDER_SELECTION: if (component is AssistantProviderSelection providerSelection) { diff --git a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor.cs b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor.cs index 7c302583..6ffee912 100644 --- a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor.cs +++ b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; @@ -47,6 +48,7 @@ public partial class AssistantDynamic : AssistantBaseCore private readonly Dictionary fileContentFields = new(); private readonly Dictionary colorPickerFields = new(); private readonly Dictionary imageCache = new(); + private readonly HashSet executingButtonActions = []; private string pluginPath = string.Empty; private const string PLUGIN_SCHEME = "plugin://"; private const string ASSISTANT_QUERY_KEY = "assistantId"; @@ -441,6 +443,115 @@ public partial class AssistantDynamic : AssistantBaseCore private string? GetOptionalStyle(string? style) => string.IsNullOrWhiteSpace(style) ? null : style; + private bool IsButtonActionRunning(string buttonName) => this.executingButtonActions.Contains(buttonName); + + private async Task ExecuteButtonActionAsync(AssistantButton button) + { + if (this.assistantPlugin is null || button.Action is null || string.IsNullOrWhiteSpace(button.Name)) + return; + + if (!this.executingButtonActions.Add(button.Name)) + return; + + try + { + var input = this.BuildPromptInput(); + var cancellationToken = this.cancellationTokenSource?.Token ?? CancellationToken.None; + var result = await this.assistantPlugin.TryInvokeButtonActionAsync(button, input, cancellationToken); + if (result is not null) + this.ApplyButtonActionResult(result); + } + finally + { + this.executingButtonActions.Remove(button.Name); + await this.InvokeAsync(this.StateHasChanged); + } + } + + private void ApplyButtonActionResult(LuaTable result) + { + if (!result.TryGetValue("fields", out var fieldsValue)) + return; + + if (!fieldsValue.TryRead(out var fieldsTable)) + { + this.Logger.LogWarning("Assistant BUTTON action returned a non-table 'fields' value. The result is ignored."); + return; + } + + foreach (var pair in fieldsTable) + { + if (!pair.Key.TryRead(out var fieldName) || string.IsNullOrWhiteSpace(fieldName)) + continue; + + this.TryApplyFieldUpdate(fieldName, pair.Value); + } + } + + private void TryApplyFieldUpdate(string fieldName, LuaValue value) + { + if (this.inputFields.ContainsKey(fieldName)) + { + if (value.TryRead(out var textValue)) + this.inputFields[fieldName] = textValue ?? string.Empty; + else + this.LogFieldUpdateTypeMismatch(fieldName, "string"); + return; + } + + if (this.dropdownFields.ContainsKey(fieldName)) + { + if (value.TryRead(out var dropdownValue)) + this.dropdownFields[fieldName] = dropdownValue ?? string.Empty; + else + this.LogFieldUpdateTypeMismatch(fieldName, "string"); + return; + } + + if (this.switchFields.ContainsKey(fieldName)) + { + if (value.TryRead(out var boolValue)) + this.switchFields[fieldName] = boolValue; + else + this.LogFieldUpdateTypeMismatch(fieldName, "boolean"); + return; + } + + if (this.colorPickerFields.ContainsKey(fieldName)) + { + if (value.TryRead(out var colorValue)) + this.colorPickerFields[fieldName] = colorValue ?? string.Empty; + else + this.LogFieldUpdateTypeMismatch(fieldName, "string"); + return; + } + + if (this.webContentFields.TryGetValue(fieldName, out var webContentState)) + { + if (value.TryRead(out var webContentValue)) + webContentState.Content = webContentValue ?? string.Empty; + else + this.LogFieldUpdateTypeMismatch(fieldName, "string"); + return; + } + + if (this.fileContentFields.TryGetValue(fieldName, out var fileContentState)) + { + if (value.TryRead(out var fileContentValue)) + fileContentState.Content = fileContentValue ?? string.Empty; + else + this.LogFieldUpdateTypeMismatch(fieldName, "string"); + return; + } + + this.Logger.LogWarning("Assistant BUTTON action tried to update unknown field '{FieldName}'. The value is ignored.", fieldName); + } + + private void LogFieldUpdateTypeMismatch(string fieldName, string expectedType) + { + this.Logger.LogWarning("Assistant BUTTON action tried to write an invalid value to '{FieldName}'. Expected {ExpectedType}.", fieldName, expectedType); + } + private string? ValidateProfileSelection(AssistantProfileSelection profileSelection, Profile? profile) { if (profile == default || profile == Profile.NO_PROFILE) diff --git a/app/MindWork AI Studio/Plugins/assistants/README.md b/app/MindWork AI Studio/Plugins/assistants/README.md index a225f1d3..f0ec6565 100644 --- a/app/MindWork AI Studio/Plugins/assistants/README.md +++ b/app/MindWork AI Studio/Plugins/assistants/README.md @@ -11,6 +11,7 @@ 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`. +- `BUTTON`: invokes a Lua callback; `Props` must include `Name`, `Text`, `Action`, and may include `Variant`, `Color`, `IsFullWidth`, `Size`, `StartIcon`, `EndIcon`, `IconColor`, `IconSize`, `Class`, `Style`. - `SWITCH`: boolean option; requires `Name`, `Label`, `Value`, and may include `Disabled`, `UserPrompt`, `LabelOn`, `LabelOff`, `LabelPlacement`, `Icon`, `IconColor`, `CheckedColor`, `UncheckedColor`, `Class`, `Style`. - `COLOR_PICKER`: color input based on `MudColorPicker`; requires `Name`, `Label`, and may include `Placeholder`, `ShowAlpha`, `ShowToolbar`, `ShowModeSwitch`, `PickerVariant`, `UserPrompt`, `Class`, `Style`. - `PROVIDER_SELECTION` / `PROFILE_SELECTION`: hooks into the shared provider/profile selectors. @@ -157,6 +158,69 @@ Example: } ``` +### `BUTTON` reference +- Use `Type = "BUTTON"` to render a clickable action button. +- Required props: + - `Name`: unique identifier used to track execution state and logging. + - `Text`: visible button label. + - `Action`: Lua function called on button click. +- Optional props: + - `Variant`: one of the MudBlazor `Variant` enum names such as `Filled`, `Outlined`, `Text`; omitted values fall back to `Filled`. + - `Color`: one of the MudBlazor `Color` enum names such as `Default`, `Primary`, `Secondary`, `Info`; omitted values fall back to `Default`. + - `IsFullWidth`: defaults to `false`; when `true`, the button expands to the available width. + - `Size`: one of the MudBlazor `Size` enum names such as `Small`, `Medium`, `Large`; omitted values fall back to `Medium`. + - `StartIcon`: MudBlazor icon identifier string rendered before the button text. + - `EndIcon`: MudBlazor icon identifier string rendered after the button text. + - `IconColor`: one of the MudBlazor `Color` enum names; omitted values fall back to `Inherit`. + - `IconSize`: one of the MudBlazor `Size` enum names; omitted values fall back to `Medium`. + - `Class`, `Style`: forwarded to the rendered component for layout/styling. + +#### `Action(input)` contract +- The function receives the same `input` structure as `ASSISTANT.BuildPrompt(input)`. +- Return `nil` for no state update. +- To update component state, return a table with a `fields` table. +- `fields` keys must reference existing component `Name` values. +- Supported write targets: + - `TEXT_AREA`, `DROPDOWN`, `WEB_CONTENT_READER`, `FILE_CONTENT_READER`, `COLOR_PICKER`: string values + - `SWITCH`: boolean values +- Unknown field names and wrong value types are ignored and logged. + +Example: +```lua +{ + ["Type"] = "BUTTON", + ["Props"] = { + ["Name"] = "buildEmailOutput", + ["Text"] = "Build output", + ["Variant"] = "Filled", + ["Color"] = "Primary", + ["IsFullWidth"] = false, + ["Size"] = "Medium", + ["StartIcon"] = "Icons.Material.Filled.AutoFixHigh", + ["EndIcon"] = "Icons.Material.Filled.ArrowForward", + ["IconColor"] = "Inherit", + ["IconSize"] = "Medium", + ["Action"] = function(input) + local email = input.fields.emailContent or "" + local translate = input.fields.translateEmail or false + local output = email + + if translate then + output = output .. "\n\nTranslate this email." + end + + return { + fields = { + outputBuffer = output + } + } + end, + ["Class"] = "mb-3", + ["Style"] = "min-width: 12rem;" + } +} +``` + ### `SWITCH` reference - Use `Type = "SWITCH"` to render a boolean toggle. - Required props: diff --git a/app/MindWork AI Studio/Plugins/assistants/plugin.lua b/app/MindWork AI Studio/Plugins/assistants/plugin.lua index 1053c83e..6b5cbab5 100644 --- a/app/MindWork AI Studio/Plugins/assistants/plugin.lua +++ b/app/MindWork AI Studio/Plugins/assistants/plugin.lua @@ -120,6 +120,38 @@ ASSISTANT = { ["Style"] = "", } }, + { + ["Type"] = "BUTTON", + ["Props"] = { + ["Name"] = "buildEmailOutput", + ["Text"] = "Build email output", + ["Size"] = "", -- size of the button. Defaults to Medium + ["Variant"] = "", -- display variation to use. Defaults to Text + ["Color"] = "", -- color of the button. Defaults to Default + ["IsFullWidth"] = false, -- ignores sizing and renders a long full width button. Defaults to false + ["StartIcon"] = "Icons.Material.Filled.ArrowRight", -- icon displayed before the text. Defaults to null + ["EndIcon"] = "Icons.Material.Filled.ArrowLeft", -- icon displayed after the text. Defaults to null + ["IconColor"] = "", -- color of start and end icons. Defaults to Inherit + ["IconSize"] = "", -- size of icons. Defaults to null. When null, the value of ["Size"] is used + ["Action"] = function(input) + local email = input.fields.emailContent or "" + local translate = input.fields.translateEmail or false + local output = email + + if translate then + output = output .. "\n\nTranslate this email." + end + + return { + fields = { + outputBuffer = output + } + } + end, + ["Class"] = "", + ["Style"] = "", + } + }, { ["Type"] = "PROVIDER_SELECTION", -- required ["Props"] = { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantButton.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantButton.cs index 8f190f0b..cc079354 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantButton.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantButton.cs @@ -1,6 +1,8 @@ -namespace AIStudio.Tools.PluginSystem.Assistants.DataModel; +using Lua; -internal sealed class AssistantButton : AssistantComponentBase +namespace AIStudio.Tools.PluginSystem.Assistants.DataModel; + +public sealed class AssistantButton : AssistantComponentBase { public override AssistantComponentType Type => AssistantComponentType.BUTTON; public override Dictionary Props { get; set; } = new(); @@ -11,16 +13,65 @@ internal sealed class AssistantButton : AssistantComponentBase get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Name)); set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Name), value); } + public string Text { get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Text)); set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Text), value); } - - public string Action + + public LuaFunction? Action { - get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Action)); - set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Action), value); + get => this.Props.TryGetValue(nameof(this.Action), out var value) && value is LuaFunction action ? action : null; + set => AssistantComponentPropHelper.WriteObject(this.Props, nameof(this.Action), value); + } + + public string Variant + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Variant)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Variant), value); + } + + public string Color + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Color)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Color), value); + } + + public bool IsFullWidth + { + get => AssistantComponentPropHelper.ReadBool(this.Props, nameof(this.IsFullWidth), false); + set => AssistantComponentPropHelper.WriteBool(this.Props, nameof(this.IsFullWidth), value); + } + + public string StartIcon + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.StartIcon)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.StartIcon), value); + } + + public string EndIcon + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.EndIcon)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.EndIcon), value); + } + + public string IconColor + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.IconColor)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.IconColor), value); + } + + public string IconSize + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.IconSize)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.IconSize), value); + } + + public string Size + { + get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Size)); + set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Size), value); } public string Class @@ -34,4 +85,7 @@ internal sealed class AssistantButton : AssistantComponentBase get => AssistantComponentPropHelper.ReadString(this.Props, nameof(this.Style)); set => AssistantComponentPropHelper.WriteString(this.Props, nameof(this.Style), value); } + + public Variant GetButtonVariant() => Enum.TryParse(this.Variant, out var variant) ? variant : MudBlazor.Variant.Filled; + } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs index 44c37ee7..af7428d1 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantComponentPropHelper.cs @@ -1,3 +1,5 @@ +using AIStudio.Tools.PluginSystem.Assistants.Icons; + namespace AIStudio.Tools.PluginSystem.Assistants.DataModel; internal static class AssistantComponentPropHelper @@ -49,4 +51,16 @@ internal static class AssistantComponentPropHelper { props[key] = value; } + + public static void WriteObject(Dictionary props, string key, object? value) + { + if (value is null) + props.Remove(key); + else + props[key] = value; + } + + public static MudBlazor.Color GetColor(string value, Color fallback) => Enum.TryParse(value, out var color) ? color : fallback; + public static string GetIconSvg(string value) => MudBlazorIconRegistry.TryGetSvg(value, out var svg) ? svg : string.Empty; + public static Size GetComponentSize(string value, Size fallback) => Enum.TryParse(value, out var size) ? size : fallback; } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/ComponentPropSpecs.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/ComponentPropSpecs.cs index b3e54833..a5e521e6 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/ComponentPropSpecs.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/ComponentPropSpecs.cs @@ -19,7 +19,10 @@ public static class ComponentPropSpecs ), [AssistantComponentType.BUTTON] = new( required: ["Name", "Text", "Action"], - optional: ["Class", "Style"] + optional: [ + "Variant", "Color", "IsFullWidth", "Size", + "StartIcon", "EndIcon", "IconColor", "IconSize", "Class", "Style" + ] ), [AssistantComponentType.DROPDOWN] = new( required: ["Name", "Label", "Default", "Items"], diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs index 7e8f6997..42833d78 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs @@ -143,6 +143,34 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType } } + public async Task TryInvokeButtonActionAsync(AssistantButton button, LuaTable input, CancellationToken cancellationToken = default) + { + if (button.Action is null) + return null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + var results = await this.state.CallAsync(button.Action, [input]); + if (results.Length == 0) + return null; + + if (results[0].Type is LuaValueType.Nil) + return null; + + if (results[0].TryRead(out var updateTable)) + return updateTable; + + LOGGER.LogWarning("Assistant plugin '{PluginName}' BUTTON '{ButtonName}' returned a non-table value. The result is ignored.", this.Name, button.Name); + return null; + } + catch (Exception e) + { + LOGGER.LogError(e, "Assistant plugin '{PluginName}' BUTTON '{ButtonName}' action failed to execute.", this.Name, button.Name); + return null; + } + } + /// /// Parses the root FORM component and start to parse its required children (main ui components) /// @@ -280,7 +308,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType LOGGER.LogWarning($"Component {type} missing required prop '{key}'."); return false; } - if (!this.TryConvertLuaValue(luaVal, out var dotNetVal)) + if (!this.TryConvertComponentPropValue(type, key, luaVal, out var dotNetVal)) { LOGGER.LogWarning($"Component {type}: prop '{key}' has wrong type."); return false; @@ -293,7 +321,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType if (!propsTable.TryGetValue(key, out var luaVal)) continue; - if (!this.TryConvertLuaValue(luaVal, out var dotNetVal)) + if (!this.TryConvertComponentPropValue(type, key, luaVal, out var dotNetVal)) { LOGGER.LogWarning($"Component {type}: optional prop '{key}' has wrong type, skipping."); continue; @@ -303,6 +331,17 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType return true; } + + private bool TryConvertComponentPropValue(AssistantComponentType type, string key, LuaValue val, out object result) + { + if (type == AssistantComponentType.BUTTON && key == "Action" && val.TryRead(out var action)) + { + result = action; + return true; + } + + return this.TryConvertLuaValue(val, out result); + } private bool TryConvertLuaValue(LuaValue val, out object result) {