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)
{