using AIStudio.Provider; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.ToolCallingSystem; using Microsoft.AspNetCore.Components; namespace AIStudio.Dialogs.Settings; public partial class ToolSettingsExportDialog : SettingsDialogBase { /// /// The ID of the collection whose settings to export. The ID of one of its tools stands for the whole collection. /// [Parameter] public string CollectionId { get; set; } = string.Empty; [Inject] private ToolRegistry ToolRegistry { get; init; } = null!; [Inject] private ToolSettingsService ToolSettingsService { get; init; } = null!; [Inject] private ILogger Logger { get; init; } = null!; private ToolCatalogItem? item; private IReadOnlyList areas = []; private HashSet selectedAreaIds = new(StringComparer.Ordinal); private readonly HashSet configuredSecretFields = []; private readonly HashSet emptyFields = []; private ToolSettingsExportMode mode = ToolSettingsExportMode.LOCKED; private bool includeSecrets; private bool includeMinimumProviderConfidence = true; private bool isLoading = true; private bool isExporting; private bool isDisposed; private string message = string.Empty; private Severity messageSeverity = Severity.Error; private bool IsAdmin => this.SettingsManager.ConfigurationData.App.ShowAdminSettings; private bool AllAreasSelected => this.areas.Count > 0 && this.areas.All(area => this.selectedAreaIds.Contains(area.Id)); private bool HasSelectedSecrets => this.SelectedFields.Any(this.configuredSecretFields.Contains); private bool CanIncludeSecrets => this.HasSelectedSecrets && PluginFactory.EnterpriseEncryption?.IsAvailable is true; /// /// How many of the selected settings hold no value, counting a field shared by two areas once. /// /// /// Saving a tool's settings writes every field of its schema, empty ones included, so an area /// the administrator never filled in still exports. Locked, those empty values are what the /// recipient is left with and cannot change, which is worth saying before the export. /// private int EmptySelectedFieldCount => this.SelectedFields.Count(this.emptyFields.Contains); /// /// The fields of all selected areas, each once. /// private IEnumerable SelectedFields => this.areas .Where(area => this.selectedAreaIds.Contains(area.Id)) .SelectMany(area => area.Area.FieldNames.Select(fieldName => new ToolField(area.Tool.Definition.Id, fieldName))) .Distinct(); private bool WarnAboutEmptyLockedSettings => this.mode is ToolSettingsExportMode.LOCKED && this.EmptySelectedFieldCount > 0; private bool CanExport => this.IsAdmin && !this.isLoading && !this.isExporting && !this.isDisposed && this.item is not null && (this.selectedAreaIds.Count > 0 || this.includeMinimumProviderConfidence); protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); if (!this.IsAdmin) { this.Close(); return; } try { this.item = await this.ToolRegistry.GetCatalogItemAsync(this.CollectionId); if (this.item is null) return; this.areas = ToolSettingsService.GetExportAreas(this.item.Tools); this.selectedAreaIds = this.areas.Select(area => area.Id).ToHashSet(StringComparer.Ordinal); // Retain only field names, never the values themselves, so no plaintext secret lives // in this component. ExportAsync reads effective settings again when the // administrator exports. foreach (var tool in this.item.Tools) { var values = await this.ToolSettingsService.GetSettingsAsync(tool.Definition); var properties = tool.Definition.SettingsSchema.Properties; this.configuredSecretFields.UnionWith(properties .Where(property => property.Value.Secret && values.TryGetValue(property.Key, out var value) && !string.IsNullOrWhiteSpace(value)) .Select(property => new ToolField(tool.Definition.Id, property.Key))); // A field the export writes as an empty value: it has to be present, because a // missing one is skipped rather than exported, and it has to be a non-secret, // because an empty secret is skipped as well. this.emptyFields.UnionWith(properties .Where(property => !property.Value.Secret && values.TryGetValue(property.Key, out var value) && string.IsNullOrWhiteSpace(value)) .Select(property => new ToolField(tool.Definition.Id, property.Key))); } } catch (Exception e) { // A runtime error may contain secret data, so it goes to the log for diagnosis but // never into the dialog: this.Logger.LogError(e, "Failed to load the configuration of the tool collection '{CollectionId}' for export.", this.CollectionId); this.item = null; this.message = T("The tool configuration could not be loaded. Please close this dialog and try again."); } finally { this.isLoading = false; } } private void SelectArea(string areaId, bool selected) { if (selected) this.selectedAreaIds.Add(areaId); else this.selectedAreaIds.Remove(areaId); this.SelectionChanged(); } private void SelectAllAreas(bool selected) { this.selectedAreaIds = selected ? this.areas.Select(area => area.Id).ToHashSet(StringComparer.Ordinal) : new(StringComparer.Ordinal); this.SelectionChanged(); } private void SelectionChanged() { // A new selection must not keep an invisible opt-in to secrets it no longer contains. if (!this.CanIncludeSecrets) this.includeSecrets = false; this.message = string.Empty; } private string GetMinimumProviderConfidenceName() { var confidence = this.item is null ? ConfidenceLevel.NONE : this.ToolRegistry.GetMinimumProviderConfidence(this.item.Id); return confidence is ConfidenceLevel.NONE ? T("No minimum confidence level chosen") : confidence.GetName(); } private async Task Export() { if (!this.CanExport || this.item is null) return; this.isExporting = true; this.message = string.Empty; this.messageSeverity = Severity.Error; try { var options = new ToolSettingsExportOptions { SelectedAreaIds = new HashSet(this.selectedAreaIds, StringComparer.Ordinal), Mode = this.mode, IncludeSecrets = this.includeSecrets, IncludeMinimumProviderConfidence = this.includeMinimumProviderConfidence, }; var result = await this.ToolSettingsService.ExportAsync(this.item.Tools, options, this.item.Id, this.ToolRegistry.GetMinimumProviderConfidence(this.item.Id)); if (this.isDisposed || !this.IsAdmin) return; if (!result.Success) { this.message = result.ErrorMessage; return; } if (string.IsNullOrWhiteSpace(result.LuaCode)) { this.messageSeverity = Severity.Info; this.message = T("The selected areas contain no settings to export."); return; } // The runtime reports clipboard success or failure. Keep the dialog open so that // administrators can retry or export another selection from the same tool. await this.RustService.CopyText2Clipboard(result.LuaCode); } catch (Exception e) { this.Logger.LogError(e, "Failed to export the configuration of the tool collection '{CollectionId}'.", this.CollectionId); this.message = T("The tool configuration could not be exported. Please try again."); } finally { this.isExporting = false; } } protected override void DisposeResources() { this.isDisposed = true; base.DisposeResources(); } /// /// One settings field of one tool. A collection may hold two tools whose fields share a name. /// private readonly record struct ToolField(string ToolId, string FieldName); }