Added admin dialog for tool configuration exports (#949)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-05 19:16:54 +02:00
1 parent 4141e7c504
commit 88bf475847
24 files changed
+921 -102

No files matched your search

@@ -0,0 +1,9 @@
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// One independently selectable area of a tool's configuration export.
/// </summary>
/// <param name="Id">A stable ID, independent of the translated label. The empty ID denotes ungrouped settings.</param>
/// <param name="Label">The translated name shown to the administrator.</param>
/// <param name="FieldNames">Settings schema field names, without the tool ID prefix.</param>
public sealed record ExportableSettings(string Id, string Label, IReadOnlyList<string> FieldNames);
@@ -64,6 +64,29 @@ public interface IToolImplementation
/// </summary>
public IReadOnlyList<ToolSettingsGroupLink> GetSettingsGroupLinks(string groupKey) => [];
/// <summary>
/// Independently selectable areas of this tool's configuration export.
/// </summary>
/// <remarks>
/// By default, each settings group is one area, including an area for ungrouped fields.
/// Override this when the export needs a different partition. IDs must be unique and stable;
/// labels must be translated. Areas contain schema field names, never values or secrets.
/// Selecting an area does not implicitly include general settings or other areas, and a
/// field hidden in the settings dialog is still exportable.
/// </remarks>
public IReadOnlyList<ExportableSettings> GetExportableSettings(ToolDefinition definition) => definition.SettingsSchema.Properties
.GroupBy(property => property.Value.Group, StringComparer.Ordinal)
.Select(group =>
{
var label = this.GetSettingsGroupLabel(group.Key);
return new ExportableSettings(
group.Key,
string.IsNullOrEmpty(label) ? TB("General") : label,
group.Select(property => property.Key).ToList()
);
})
.ToList();
/// <summary>
/// Whether one settings field is worth showing, given what is filled in at the moment.
/// </summary>
@@ -0,0 +1,25 @@
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// How firmly an exported tool setting applies to the people who receive the configuration plugin.
/// </summary>
/// <remarks>
/// Chosen per export, and it covers the ordinary settings only. A secret is always locked, no
/// matter which mode is picked, because a pre-filled secret is one the user may save as their
/// own — see the tool settings service for that rule. The minimum provider confidence is
/// likewise a fixed requirement.
/// </remarks>
public enum ToolSettingsExportMode
{
/// <summary>
/// The organization fixes the value: it goes into LockedToolSettings, the user cannot change
/// it, and it is reapplied on every configuration update.
/// </summary>
LOCKED,
/// <summary>
/// The organization pre-fills the value: it goes into DefaultToolSettings, and a value the
/// user saves afterwards wins over it.
/// </summary>
DEFAULT,
}
@@ -0,0 +1,15 @@
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// The administrator's choices for one export. The dialog initially selects every available area.
/// </summary>
public sealed record ToolSettingsExportOptions
{
public IReadOnlySet<string> SelectedAreaIds { get; init; } = new HashSet<string>(StringComparer.Ordinal);
public ToolSettingsExportMode Mode { get; init; } = ToolSettingsExportMode.LOCKED;
public bool IncludeSecrets { get; init; }
public bool IncludeMinimumProviderConfidence { get; init; } = true;
}
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.ToolCallingSystem;
/// <summary>
/// Lua to copy, or an explanation of why the export failed. An empty successful export has nothing to copy.
/// </summary>
public sealed record ToolSettingsExportResult(string LuaCode = "", string ErrorMessage = "")
{
public bool Success => string.IsNullOrEmpty(this.ErrorMessage);
}
@@ -0,0 +1,133 @@
using System.Text;
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
using SharedTools;
namespace AIStudio.Tools.ToolCallingSystem;
public sealed partial class ToolSettingsService
{
private const string LOCKED_SETTINGS = "DataTools.LockedToolSettings";
private const string DEFAULT_SETTINGS = "DataTools.DefaultToolSettings";
private const string MINIMUM_CONFIDENCE = "DataTools.MinimumProviderConfidenceByToolId";
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ToolSettingsService).Namespace, nameof(ToolSettingsService));
/// <summary>
/// Reads the saved, effective configuration and exports the selected areas. Incomplete tools
/// may be exported too: administrators can finish the configuration in their Lua plugin.
/// </summary>
/// <remarks>
/// Uses the same organization overrides and keyring values as tool execution, without saving
/// settings or writing to the keyring. The caller provides the admin-only UI and copies a
/// successful, nonempty result to the clipboard.<br/><br/>
/// Only explicitly selected areas are included. Missing values stay absent, explicitly empty
/// non-secret values stay empty, and runtime defaults are not filled in. Secrets require
/// opt-in and enterprise encryption, and are always locked, even in a default-value export.
/// The optional minimum provider confidence is also always a fixed requirement.
/// </remarks>
public async Task<ToolSettingsExportResult> ExportAsync(ToolDefinition definition, IToolImplementation implementation, ToolSettingsExportOptions options)
{
var areas = implementation.GetExportableSettings(definition);
var values = await this.GetSettingsAsync(definition);
var confidence = settingsManager.GetMinimumProviderConfidenceForTool(definition.Id, definition.MinimumProviderConfidence);
return BuildConfigurationSection(definition, areas, values, options, confidence, PluginFactory.EnterpriseEncryption);
}
/// <summary>
/// Resolves selected areas to known fields in schema order. Overlapping areas include a
/// field only once; unknown field names are ignored. Form visibility does not limit exports.
/// </summary>
private static IReadOnlyList<string> GetSelectedFieldNames(ToolDefinition definition, IReadOnlyList<ExportableSettings> areas, IReadOnlySet<string> selectedAreaIds)
{
var selectedIds = new HashSet<string>(selectedAreaIds, StringComparer.Ordinal);
var selectedFields = areas.Where(area => selectedIds.Contains(area.Id))
.SelectMany(area => area.FieldNames)
.ToHashSet(StringComparer.Ordinal);
return definition.SettingsSchema.Properties.Keys.Where(selectedFields.Contains).ToList();
}
/// <summary>
/// Builds a fragment from one snapshot. A failed encryption returns no Lua, even when other
/// fields have already been processed, so the caller cannot copy a partial export by accident.
/// </summary>
private static ToolSettingsExportResult BuildConfigurationSection(ToolDefinition definition, IReadOnlyList<ExportableSettings> areas, IReadOnlyDictionary<string, string> values, ToolSettingsExportOptions options, ConfidenceLevel minimumProviderConfidence, EnterpriseEncryption? encryption)
{
var lockedValues = new Dictionary<string, string>(StringComparer.Ordinal);
var defaultValues = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var fieldName in GetSelectedFieldNames(definition, areas, options.SelectedAreaIds))
{
if (!values.TryGetValue(fieldName, out var value))
continue;
var key = ManagedSettingKey(definition.Id, fieldName);
if (definition.SettingsSchema.Properties[fieldName].Secret)
{
if (!options.IncludeSecrets || string.IsNullOrWhiteSpace(value))
continue;
if (encryption?.IsAvailable is not true)
return new(ErrorMessage: TB("Cannot export encrypted tool secrets: No enterprise encryption secret is configured."));
if (!encryption.TryEncrypt(value, out var encrypted))
return new(ErrorMessage: TB("The tool secrets could not be encrypted. Nothing was exported."));
lockedValues[key] = encrypted;
}
else if (options.Mode is ToolSettingsExportMode.LOCKED)
lockedValues[key] = value;
else
defaultValues[key] = value;
}
if (lockedValues.Count is 0 && defaultValues.Count is 0 && !options.IncludeMinimumProviderConfidence)
return new();
if (options.IncludeMinimumProviderConfidence && (!Enum.IsDefined(minimumProviderConfidence) || minimumProviderConfidence is ConfidenceLevel.UNKNOWN))
return new(ErrorMessage: TB("The tool's minimum provider confidence level is invalid."));
var lua = new StringBuilder();
AppendSettings(lua, LOCKED_SETTINGS, lockedValues);
AppendSettings(lua, DEFAULT_SETTINGS, defaultValues);
if (options.IncludeMinimumProviderConfidence)
{
AppendSettings(lua, MINIMUM_CONFIDENCE, new Dictionary<string, string>(StringComparer.Ordinal)
{
[definition.Id] = minimumProviderConfidence.ToString(),
});
//
// A managed setting without an AllowUserOverride flag is locked anyway, so writing
// "= false" here would only restate the default — and would silently undo an
// administrator's own "= true" further up in the same plugin, for the whole
// dictionary rather than this tool's entry. A comment says the same thing without
// overwriting anything:
//
lua.AppendLine($"-- The whole table is locked unless you set CONFIG[\"SETTINGS\"][\"{MINIMUM_CONFIDENCE}.AllowUserOverride\"] = true");
}
return new(LuaCode: lua.ToString());
}
/// <summary>
/// Adds entries without replacing the table, so administrators can combine export fragments
/// in one plugin. Later assignments to the same key win. This does not merge dictionaries
/// across separate configuration plugins; those still follow managed-setting precedence.
/// </summary>
private static void AppendSettings(StringBuilder lua, string settingName, IReadOnlyDictionary<string, string> values)
{
if (values.Count is 0)
return;
var table = $"CONFIG[\"SETTINGS\"][\"{settingName}\"]";
if (lua.Length > 0)
lua.AppendLine();
lua.AppendLine($"{table} = {table} or {{}}");
foreach (var (key, value) in values)
lua.AppendLine($"{table}[\"{LuaTools.EscapeLuaString(key)}\"] = \"{LuaTools.EscapeLuaString(value)}\"");
}
}
@@ -4,7 +4,7 @@ using AIStudio.Tools.Services;
namespace AIStudio.Tools.ToolCallingSystem;
public sealed class ToolSettingsService(SettingsManager settingsManager, RustService rustService, ILogger<ToolSettingsService> logger)
public sealed partial class ToolSettingsService(SettingsManager settingsManager, RustService rustService, ILogger<ToolSettingsService> logger)
{
/// <summary>
/// Builds the key under which an organization's configuration addresses one tool setting.
@@ -202,4 +202,4 @@ public sealed class ToolSettingsService(SettingsManager settingsManager, RustSer
secret = decryptedSecret;
return true;
}
}
}