using System.Globalization; using System.Text; using AIStudio.Dialogs.Settings; using AIStudio.Settings; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants.DataModel; using Lua; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; namespace AIStudio.Assistants.Dynamic; public partial class AssistantDynamic : AssistantBaseCore { [Parameter] public AssistantForm? RootComponent { get; set; } = null!; protected override string Title => this.title; protected override string Description => this.description; protected override string SystemPrompt => this.systemPrompt; protected override bool AllowProfiles => this.allowProfiles; protected override bool ShowProfileSelection => this.showFooterProfileSelection; protected override string SubmitText => this.submitText; protected override Func SubmitAction => this.Submit; // Dynamic assistants do not have dedicated settings yet. // Reuse chat-level provider filtering/preselection instead of NONE. protected override Tools.Components Component => Tools.Components.CHAT; private string title = string.Empty; private string description = string.Empty; private string systemPrompt = string.Empty; private bool allowProfiles = true; private string submitText = string.Empty; private bool showFooterProfileSelection = true; private PluginAssistants? assistantPlugin; private readonly AssistantState assistantState = new(); private readonly Dictionary imageCache = new(); private readonly HashSet executingButtonActions = []; private readonly HashSet executingSwitchActions = []; private string pluginPath = string.Empty; private const string PLUGIN_SCHEME = "plugin://"; private const string ASSISTANT_QUERY_KEY = "assistantId"; private static readonly CultureInfo INVARIANT_CULTURE = CultureInfo.InvariantCulture; private static readonly string[] FALLBACK_DATE_FORMATS = ["yyyy-MM-dd", "dd.MM.yyyy", "MM/dd/yyyy"]; private static readonly string[] FALLBACK_TIME_FORMATS = ["HH:mm", "HH:mm:ss", "hh:mm tt", "h:mm tt"]; protected override void OnInitialized() { var assistantPlugin = this.ResolveAssistantPlugin(); if (assistantPlugin is null) { this.Logger.LogWarning("AssistantDynamic could not resolve a registered assistant plugin."); base.OnInitialized(); return; } this.assistantPlugin = assistantPlugin; this.RootComponent = assistantPlugin.RootComponent; this.title = assistantPlugin.AssistantTitle; this.description = assistantPlugin.AssistantDescription; this.systemPrompt = assistantPlugin.SystemPrompt; this.submitText = assistantPlugin.SubmitText; this.allowProfiles = assistantPlugin.AllowProfiles; this.showFooterProfileSelection = !assistantPlugin.HasEmbeddedProfileSelection; this.pluginPath = assistantPlugin.PluginPath; var rootComponent = this.RootComponent; if (rootComponent is not null) { this.InitializeComponentState(rootComponent.Children); } base.OnInitialized(); } private PluginAssistants? ResolveAssistantPlugin() { var assistantPlugins = PluginFactory.RunningPlugins.OfType() .Where(plugin => this.SettingsManager.IsPluginEnabled(plugin)) .ToList(); if (assistantPlugins.Count == 0) return null; var requestedPluginId = this.TryGetAssistantIdFromQuery(); if (requestedPluginId is not { } id) return assistantPlugins.First(); var requestedPlugin = assistantPlugins.FirstOrDefault(p => p.Id == id); return requestedPlugin ?? assistantPlugins.First(); } private Guid? TryGetAssistantIdFromQuery() { var uri = this.NavigationManager.ToAbsoluteUri(this.NavigationManager.Uri); if (string.IsNullOrWhiteSpace(uri.Query)) return null; var query = QueryHelpers.ParseQuery(uri.Query); if (!query.TryGetValue(ASSISTANT_QUERY_KEY, out var values)) return null; var value = values.FirstOrDefault(); if (string.IsNullOrWhiteSpace(value)) return null; if (Guid.TryParse(value, out var assistantId)) return assistantId; this.Logger.LogWarning("AssistantDynamic query parameter '{Parameter}' is not a valid GUID.", value); return null; } protected override void ResetForm() { this.assistantState.Clear(); var rootComponent = this.RootComponent; if (rootComponent is not null) this.InitializeComponentState(rootComponent.Children); } protected override bool MightPreselectValues() { // Dynamic assistants have arbitrary fields supplied via plugins, so there // isn't a built-in settings section to prefill values. Always return // false to keep the plugin-specified defaults. return false; } private string ResolveImageSource(AssistantImage image) { if (string.IsNullOrWhiteSpace(image.Src)) return string.Empty; if (this.imageCache.TryGetValue(image.Src, out var cached) && !string.IsNullOrWhiteSpace(cached)) return cached; var resolved = image.Src; if (resolved.StartsWith(PLUGIN_SCHEME, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(this.pluginPath)) { var relative = resolved[PLUGIN_SCHEME.Length..].TrimStart('/', '\\').Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); var filePath = Path.Join(this.pluginPath, relative); if (File.Exists(filePath)) { var mime = GetImageMimeType(filePath); var data = Convert.ToBase64String(File.ReadAllBytes(filePath)); resolved = $"data:{mime};base64,{data}"; } else { resolved = string.Empty; } } else if (Uri.TryCreate(resolved, UriKind.Absolute, out var uri)) { if (uri.Scheme is not ("http" or "https" or "data")) resolved = string.Empty; } else { resolved = string.Empty; } this.imageCache[image.Src] = resolved; return resolved; } private static string GetImageMimeType(string path) { var extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(); return extension switch { "svg" => "image/svg+xml", "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "bmp" => "image/bmp", _ => "image/png", }; } private async Task CollectUserPromptAsync() { if (this.assistantPlugin?.HasCustomPromptBuilder != true) return this.CollectUserPromptFallback(); var input = this.BuildPromptInput(); var prompt = await this.assistantPlugin.TryBuildPromptAsync(input, this.cancellationTokenSource?.Token ?? CancellationToken.None); return !string.IsNullOrWhiteSpace(prompt) ? prompt : this.CollectUserPromptFallback(); } private LuaTable BuildPromptInput() { var input = new LuaTable(); var rootComponent = this.RootComponent; input["state"] = rootComponent is not null ? this.assistantState.ToLuaTable(rootComponent.Children) : new LuaTable(); var profile = new LuaTable { ["Name"] = this.currentProfile.Name, ["NeedToKnow"] = this.currentProfile.NeedToKnow, ["Actions"] = this.currentProfile.Actions, ["Num"] = this.currentProfile.Num, }; input["profile"] = profile; return input; } private string CollectUserPromptFallback() { var prompt = string.Empty; var rootComponent = this.RootComponent; if (rootComponent is null) return prompt; return this.CollectUserPromptFallback(rootComponent.Children); } private void InitializeComponentState(IEnumerable components) { foreach (var component in components) { if (component is IStatefulAssistantComponent statefulComponent) statefulComponent.InitializeState(this.assistantState); if (component.Children.Count > 0) this.InitializeComponentState(component.Children); } } private static string MergeClass(string customClass, string fallback) { var trimmedCustom = customClass?.Trim() ?? string.Empty; var trimmedFallback = fallback?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(trimmedCustom)) return trimmedFallback; if (string.IsNullOrEmpty(trimmedFallback)) return trimmedCustom; return $"{trimmedCustom} {trimmedFallback}"; } private string? GetOptionalStyle(string? style) => string.IsNullOrWhiteSpace(style) ? null : style; private bool IsButtonActionRunning(string buttonName) => this.executingButtonActions.Contains(buttonName); private bool IsSwitchActionRunning(string switchName) => this.executingSwitchActions.Contains(switchName); 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.ApplyActionResult(result, AssistantComponentType.BUTTON); } finally { this.executingButtonActions.Remove(button.Name); await this.InvokeAsync(this.StateHasChanged); } } private async Task ExecuteSwitchChangedAsync(AssistantSwitch switchComponent, bool value) { if (string.IsNullOrWhiteSpace(switchComponent.Name)) return; this.assistantState.Bools[switchComponent.Name] = value; if (this.assistantPlugin is null || switchComponent.OnChanged is null) { await this.InvokeAsync(this.StateHasChanged); return; } if (!this.executingSwitchActions.Add(switchComponent.Name)) return; try { var input = this.BuildPromptInput(); var cancellationToken = this.cancellationTokenSource?.Token ?? CancellationToken.None; var result = await this.assistantPlugin.TryInvokeSwitchChangedAsync(switchComponent, input, cancellationToken); if (result is not null) this.ApplyActionResult(result, AssistantComponentType.SWITCH); } finally { this.executingSwitchActions.Remove(switchComponent.Name); await this.InvokeAsync(this.StateHasChanged); } } private void ApplyActionResult(LuaTable result, AssistantComponentType sourceType) { if (!result.TryGetValue("fields", out var fieldsValue)) return; if (!fieldsValue.TryRead(out var fieldsTable)) { this.Logger.LogWarning("Assistant {ComponentType} callback returned a non-table 'fields' value. The result is ignored.", sourceType); return; } foreach (var pair in fieldsTable) { if (!pair.Key.TryRead(out var fieldName) || string.IsNullOrWhiteSpace(fieldName)) continue; this.TryApplyFieldUpdate(fieldName, pair.Value, sourceType); } } private void TryApplyFieldUpdate(string fieldName, LuaValue value, AssistantComponentType sourceType) { if (this.assistantState.TryApplyValue(fieldName, value, out var expectedType)) return; if (!string.IsNullOrWhiteSpace(expectedType)) { this.Logger.LogWarning($"Assistant {sourceType} callback tried to write an invalid value to '{fieldName}'. Expected {expectedType}."); return; } this.Logger.LogWarning($"Assistant {sourceType} callback tried to update unknown field '{fieldName}'. The value is ignored."); } private EventCallback> CreateMultiselectDropdownChangedCallback(string fieldName) => EventCallback.Factory.Create>(this, values => { this.assistantState.MultiSelect[fieldName] = values; }); private string? ValidateProfileSelection(AssistantProfileSelection profileSelection, Profile? profile) { if (profile != default && profile != Profile.NO_PROFILE) return null; return !string.IsNullOrWhiteSpace(profileSelection.ValidationMessage) ? profileSelection.ValidationMessage : this.T("Please select one of your profiles."); } private async Task Submit() { this.CreateChatThread(); var time = this.AddUserRequest(await this.CollectUserPromptAsync()); await this.AddAIResponseAsync(time); } private string CollectUserPromptFallback(IEnumerable components) { var prompt = new StringBuilder(); foreach (var component in components) { if (component is IStatefulAssistantComponent statefulComponent) prompt.Append(statefulComponent.UserPromptFallback(this.assistantState)); if (component.Children.Count > 0) { prompt.Append(this.CollectUserPromptFallback(component.Children)); } } return prompt.ToString(); } private DateTime? ParseDatePickerValue(string? value, string? format) { if (string.IsNullOrWhiteSpace(value)) return null; if (TryParseDate(value, format, out var parsedDate)) return parsedDate; return null; } private void SetDatePickerValue(string fieldName, DateTime? value, string? format) { this.assistantState.Dates[fieldName] = value.HasValue ? FormatDate(value.Value, format) : string.Empty; } private DateRange? ParseDateRangePickerValue(string? value, string? format) { if (string.IsNullOrWhiteSpace(value)) return null; var parts = value.Split(" - ", 2, StringSplitOptions.TrimEntries); if (parts.Length != 2) return null; if (!TryParseDate(parts[0], format, out var start) || !TryParseDate(parts[1], format, out var end)) return null; return new DateRange(start, end); } private void SetDateRangePickerValue(string fieldName, DateRange? value, string? format) { if (value?.Start is null || value.End is null) { this.assistantState.DateRanges[fieldName] = string.Empty; return; } this.assistantState.DateRanges[fieldName] = $"{FormatDate(value.Start.Value, format)} - {FormatDate(value.End.Value, format)}"; } private TimeSpan? ParseTimePickerValue(string? value, string? format) { if (string.IsNullOrWhiteSpace(value)) return null; if (TryParseTime(value, format, out var parsedTime)) return parsedTime; return null; } private void SetTimePickerValue(string fieldName, TimeSpan? value, string? format) { this.assistantState.Times[fieldName] = value.HasValue ? FormatTime(value.Value, format) : string.Empty; } private static bool TryParseDate(string value, string? format, out DateTime parsedDate) { if (!string.IsNullOrWhiteSpace(format) && DateTime.TryParseExact(value, format, INVARIANT_CULTURE, DateTimeStyles.AllowWhiteSpaces, out parsedDate)) { return true; } if (DateTime.TryParseExact(value, FALLBACK_DATE_FORMATS, INVARIANT_CULTURE, DateTimeStyles.AllowWhiteSpaces, out parsedDate)) return true; return DateTime.TryParse(value, INVARIANT_CULTURE, DateTimeStyles.AllowWhiteSpaces, out parsedDate); } private static bool TryParseTime(string value, string? format, out TimeSpan parsedTime) { if (!string.IsNullOrWhiteSpace(format) && DateTime.TryParseExact(value, format, INVARIANT_CULTURE, DateTimeStyles.AllowWhiteSpaces, out var dateTime)) { parsedTime = dateTime.TimeOfDay; return true; } if (DateTime.TryParseExact(value, FALLBACK_TIME_FORMATS, INVARIANT_CULTURE, DateTimeStyles.AllowWhiteSpaces, out dateTime)) { parsedTime = dateTime.TimeOfDay; return true; } if (TimeSpan.TryParse(value, INVARIANT_CULTURE, out parsedTime)) return true; parsedTime = default; return false; } private static string FormatDate(DateTime value, string? format) { try { return value.ToString(string.IsNullOrWhiteSpace(format) ? "yyyy-MM-dd" : format, INVARIANT_CULTURE); } catch (FormatException) { return value.ToString("yyyy-MM-dd", INVARIANT_CULTURE); } } private static string FormatTime(TimeSpan value, string? format) { var dateTime = DateTime.Today.Add(value); try { return dateTime.ToString(string.IsNullOrWhiteSpace(format) ? "HH:mm" : format, INVARIANT_CULTURE); } catch (FormatException) { return dateTime.ToString("HH:mm", INVARIANT_CULTURE); } } }