diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 6f5c4644a..acd909b46 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -12,7 +12,7 @@ } - + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogVisualBriefing.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogVisualBriefing.razor index 8d8c67a72..539c5a23e 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogVisualBriefing.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogVisualBriefing.razor @@ -10,20 +10,20 @@ - + @if (this.SettingsManager.ConfigurationData.VisualBriefing.PreselectedTargetLanguage is CommonLanguages.OTHER) { - + } - - - - - - - - - + + + + + + + + + diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 0be310395..d98d2b769 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -406,6 +406,13 @@ CONFIG["SETTINGS"] = {} -- but users can still choose another start page in the app settings. -- CONFIG["SETTINGS"]["DataApp.StartPage.AllowUserOverride"] = true +-- Configure whether AI Studio saves energy while the AI writes its answers. +-- Allowed values are: true, false +-- When set to true, streamed answers are updated once every third second instead of as soon +-- as new text arrives. This helps on battery-powered devices and on virtual desktops, where +-- every redraw of the screen is costly. +-- CONFIG["SETTINGS"]["DataApp.IsSavingEnergy"] = true + -- Configure whether the quick start guide is shown on the welcome page. -- CONFIG["SETTINGS"]["DataApp.ShowQuickStartGuide"] = false diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs index 417d48be8..f41368058 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs @@ -14,6 +14,15 @@ public static partial class ManagedConfiguration private static ILogger Log => Program.LOGGER_FACTORY.CreateLogger(nameof(ManagedConfiguration)); + /// + /// All registered settings, in no particular order. + /// + /// + /// The data classes register their settings when the settings data is created. Once it exists, + /// this covers every managed setting, whatever its type. + /// + internal static IEnumerable RegisteredSettings => METADATA.Values.OfType(); + /// /// Attempts to retrieve the configuration metadata for a given configuration selection and /// property expression (enum-based). @@ -325,11 +334,8 @@ public static partial class ManagedConfiguration var wasChanged = false; var registeredSettingNames = new HashSet(StringComparer.Ordinal); - foreach (var config in METADATA.Values) + foreach (var configMeta in RegisteredSettings) { - if (config is not ConfigMetaBase configMeta) - continue; - registeredSettingNames.Add(configMeta.SettingName); // diff --git a/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md b/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md index a90aa34d7..61882c639 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md +++ b/app/MindWork AI Studio/Tools/PluginSystem/AGENTS.md @@ -37,7 +37,7 @@ Configuration plugins provide three kinds of values: - **Live plugin content:** complex Lua tables that implement `ILivePluginContent` and are read live from running plugins instead of being persisted to `ConfigurationData`. Examples include `MANDATORY_INFOS` and `INTRODUCTIONS`. If live plugin content creates persistent side data, add a dedicated cleanup path for that side data, like mandatory-info acceptances. When adding configuration plugin capabilities: -- For managed settings, update the corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)` and process the setting in `PluginConfiguration.TryProcessConfiguration`. Cleaning up the setting when its configuration plugin was removed needs no extra step: `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` iterates all registered settings. Do not add per-setting cleanup calls to `PluginFactory.Loading.LoadAll`. +- For managed settings, update the corresponding data class in `app/MindWork AI Studio/Settings/DataModel/` to call `ManagedConfiguration.Register(...)` and process the setting in `PluginConfiguration.TryProcessConfiguration`. Cleaning up the setting when its configuration plugin was removed needs no extra step: `ManagedConfiguration.CleanupLeftOverManagedConfigurations(...)` iterates all registered settings. Do not add per-setting cleanup calls to `PluginFactory.Loading.LoadAll`. `ManagedSettingsCoverageTests` fails for a registered setting which no configuration plugin can manage; a setting which must stay out of their reach goes into its list `EXEMPT_SETTINGS`, together with the reason. - For managed configuration objects, add the type to `PluginConfigurationObjectType.cs`, persist the objects in the appropriate `ConfigurationData` collection, and add a descriptor to `PluginConfigurationObjectDescriptors` and its list `ALL`: its Lua table, its parser, the collection, the next number, and where secrets are kept. Parsing and cleanup need no extra call; `PluginConfigurationObjectDescriptorsTests` fails for a type without a descriptor. - For live plugin content, add a data type implementing `ILivePluginContent`, parse it in `PluginConfiguration`, expose it through `PluginFactory`, and add any required cleanup only for persistent side data. - Always document the new capability in `app/MindWork AI Studio/Plugins/configuration/plugin.lua`. diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 200cfefb2..b0ee9699d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -233,6 +233,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: what should be the start page? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.StartPage, this.Id, settingsTable, dryRun); + // Config: save energy while the AI streams its answers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.IsSavingEnergy, this.Id, settingsTable, dryRun); + // Config: show prompt-injection alert dialogs? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowPromptInjectionAlert, this.Id, settingsTable, dryRun); @@ -447,6 +450,19 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); + // Config: Visual Briefing Assistant defaults? + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedProfile, Guid.Empty, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedTargetLanguage, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedOtherLanguage, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedAudienceProfile, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedAudienceAgeGroup, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedAudienceOrganizationalLevel, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.PreselectedAudienceExpertise, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.ShowSourceReferences, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.OptimizeImages, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun); + // Config: transcription provider? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index 7e43631e9..0be12e8fb 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -38,6 +38,7 @@ - Improved the Batch Processing assistant, which now leaves out hidden files and folders as well as build output and downloaded dependencies, such as the folder `node_modules`. You can change both in the expert settings of the assistant. - Improved local data sources for folders and the Batch Processing assistant, which now leave out files larger than 1 GB, such as images of virtual machines. You can change this limit. - Improved the organization-wide management of the Batch Processing assistant. IT departments can now preset whether it includes hidden files and folders, which files and folders it leaves out, and how large a file may be, either fixed or as a default which users may change. +- Improved the organization-wide management of the app settings. IT departments can now also set whether AI Studio saves energy while the AI writes its answers, which helps on virtual desktops, for example. - Improved attaching files to a chat. You can now attach any file that contains readable text, for example, program code or configuration files of a kind AI Studio did not know before. - Changed the banner on the start page to a fresh design that shows what AI Studio can do today. - Fixed a warning about the confidence level that stayed in the dialog for adding a local data source, even after you chose an embedding provider that meets it. The dialog now checks again whenever you change either of them, so a warning also appears when you switch to a provider that falls short. @@ -77,6 +78,7 @@ - Fixed the security notice about removed instructions (prompt injection) appearing only minutes later while AI Studio updated a data source in the background. The notice also mixed your own attachments with files of the data source. Now each notice appears as soon as AI Studio has read the content, for example, the attachments of your message, and it names only that content. - Fixed the security notice about removed instructions (prompt injection) appearing several times for one answer when AI Studio checks which passages of your data sources fit your question: once for every passage it checked, and once more for all of them together. You now get one notice per answer, and a file counts once, however many of its passages were affected. - Fixed the example configuration for IT departments, which spelled llama.cpp in a way AI Studio does not accept. AI Studio ignored a self-hosted provider set up with that spelling. The example now also names only the self-hosted servers which can create embeddings or transcribe audio. +- Fixed the configuration of your organization not applying its defaults for the Visual Briefing assistant, such as the provider, the language, or the audience. The settings of the assistant now also show which defaults your organization has locked. - Fixed exporting an LLM provider or an embedding provider with a tokenizer. On Windows, the export broke the configuration plugin, and on every system, the plugin ignored the tokenizer because the export named a folder on your computer. The export now names the place in your configuration plugin where the tokenizer file belongs. - Fixed building AI Studio from source on Windows, where the checks that run before every build failed because two of them expected the line breaks and file paths of macOS and Linux. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution. - Fixed a problem on Windows where AI Studio could fail to read the configuration of your organization when the name of an environment variable on your computer contained umlauts or other special characters. diff --git a/app/Tests/Tools/PluginSystem/ManagedSettingsCoverageTests.cs b/app/Tests/Tools/PluginSystem/ManagedSettingsCoverageTests.cs new file mode 100644 index 000000000..2ca577a41 --- /dev/null +++ b/app/Tests/Tools/PluginSystem/ManagedSettingsCoverageTests.cs @@ -0,0 +1,212 @@ +using System.Collections; +using System.Globalization; +using System.Text; + +using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks that a configuration plugin can manage every setting registered as a managed one. +/// +/// +/// A data class registers a managed setting with ManagedConfiguration.Register, and +/// PluginConfiguration.TryProcessConfiguration reads it from a configuration plugin. Nothing ties the +/// two together: a setting registered but never read can be locked in the user interface and appears +/// in the documentation, yet no configuration plugin ever reaches it. The defaults of the Visual +/// Briefing assistant stayed out of reach of every organization that way.

+/// The test therefore configures every registered setting in one plugin and runs it for real. A dry +/// run would not do: it reads the values but applies none of them, so it cannot tell which settings +/// were read. The settings are reached through Program.SERVICE_PROVIDER, and the managed state of each +/// setting is static, which is why this fixture does not run alongside others. +///
+[TestFixture] +[NonParallelizable] +public sealed class ManagedSettingsCoverageTests +{ + private static readonly Guid PLUGIN_ID = Guid.Parse("cd426cd3-bcff-4d6f-87bd-2c3c0756aa7d"); + + /// + /// The value configured for every setting holding a string. + /// + /// + /// Free text accepts any value, and the settings which hold the ID of a provider or a profile + /// accept nothing but a GUID, so a GUID suits them all. + /// + private const string SAMPLE_TEXT = "e2f4bebe-1a3a-4f98-bbb9-e40e0b9daa14"; + + /// + /// The registered settings this test does not expect a configuration plugin to manage, each with + /// the reason. + /// + /// + /// A setting which must stay out of reach of configuration plugins belongs here as well. Neither + /// kind of entry is a way to postpone reading a setting: a plugin which declares a setting that + /// AI Studio silently ignores is worse than a setting no plugin can declare. + /// + private static readonly IReadOnlyDictionary EXEMPT_SETTINGS = new Dictionary(StringComparer.Ordinal) + { + ["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = "PluginConfiguration reads the approvals with a parser of its own, and only from a configuration plugin of an organization. Whether a plugin is one depends on the directory it lives in, which only the plugin factory sets up.", + }; + + private RustService rustService = null!; + private ServiceProvider serviceProvider = null!; + private IServiceProvider previousServiceProvider = null!; + + [SetUp] + public void CreateSettings() + { + // Only builds its HTTP clients. Nothing connects, because the plugin of this test defines no provider: + this.rustService = new RustService("1", "unused"); + + // Creating the settings data registers every managed setting: + var settingsManager = new SettingsManager(NullLogger.Instance, this.rustService); + + this.previousServiceProvider = Program.SERVICE_PROVIDER; + this.serviceProvider = new ServiceCollection().AddSingleton(settingsManager).AddSingleton(this.rustService).BuildServiceProvider(); + Program.SERVICE_PROVIDER = this.serviceProvider; + } + + [TearDown] + public void RestoreApplicationState() + { + // + // The managed state of a setting outlives this fixture, so we release everything the plugin + // took over. The clean-up AI Studio runs after a plugin was removed does exactly that, as + // long as it still reaches the settings of this test: + // + ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet()); + + Program.SERVICE_PROVIDER = this.previousServiceProvider; + this.serviceProvider.Dispose(); + this.rustService.Dispose(); + } + + [Test] + public async Task EveryRegisteredSettingCanBeManagedByAConfigurationPlugin() + { + using var plugin = await RunPluginWithEveryRegisteredSettingAsync(); + var registeredSettings = ManagedConfiguration.RegisteredSettings.ToList(); + var unmanagedSettingNames = registeredSettings + .Where(setting => !EXEMPT_SETTINGS.ContainsKey(setting.SettingName) && !IsManagedByThePlugin(setting)) + .Select(setting => setting.SettingName) + .Order(StringComparer.Ordinal) + .ToList(); + + Assert.Multiple(() => + { + Assert.That(registeredSettings.Select(setting => setting.SettingName), Does.Contain("DataApp.UpdateInterval"), "No settings were registered, so this test would check nothing."); + Assert.That(plugin.DeclaredSettingsCount, Is.EqualTo(registeredSettings.Count), "The plugin of this test does not declare every registered setting."); + Assert.That(unmanagedSettingNames, Is.Empty, "These settings are registered with ManagedConfiguration.Register, but no configuration plugin can manage them. Read each one in PluginConfiguration.TryProcessConfiguration and document it in the example configuration plugin.lua, or add it to EXEMPT_SETTINGS with the reason it must stay out of reach of configuration plugins."); + }); + } + + [Test] + public async Task EveryExemptSettingIsRegisteredAndStaysUnmanaged() + { + using var plugin = await RunPluginWithEveryRegisteredSettingAsync(); + var registeredSettings = ManagedConfiguration.RegisteredSettings.ToDictionary(setting => setting.SettingName, StringComparer.Ordinal); + + Assert.Multiple(() => + { + foreach (var exemptSettingName in EXEMPT_SETTINGS.Keys) + { + Assert.That(registeredSettings.TryGetValue(exemptSettingName, out var exemptSetting), Is.True, $"The exempt setting '{exemptSettingName}' is not registered anymore. Remove it from EXEMPT_SETTINGS."); + if (exemptSetting is not null) + Assert.That(IsManagedByThePlugin(exemptSetting), Is.False, $"A configuration plugin can manage the exempt setting '{exemptSettingName}' by now. Remove it from EXEMPT_SETTINGS."); + } + }); + } + + /// + /// Loads a configuration plugin which declares a valid value for every registered setting, and + /// applies it the way AI Studio does when the plugin starts. + /// + /// The started configuration plugin. + private static async Task RunPluginWithEveryRegisteredSettingAsync() + { + var code = new StringBuilder($$""" + ID = "{{PLUGIN_ID}}" + NAME = "Configuration for the tests" + DESCRIPTION = "A configuration plugin which declares every managed setting." + VERSION = "1.0.0" + TYPE = "CONFIGURATION" + AUTHORS = {"MindWork AI"} + SUPPORT_CONTACT = "MindWork AI" + SOURCE_URL = "https://github.com/MindWorkAI/AI-Studio" + CATEGORIES = {"CORE"} + TARGET_GROUPS = {"EVERYONE"} + IS_MAINTAINED = true + DEPRECATION_MESSAGE = "" + CONFIG = {} + CONFIG["SETTINGS"] = {} + + """); + + foreach (var setting in ManagedConfiguration.RegisteredSettings) + code.AppendLine($"""CONFIG["SETTINGS"]["{setting.SettingName}"] = {ToLuaValue(setting)}"""); + + var plugin = await PluginFactory.Load(null, code.ToString()); + if (plugin is not PluginConfiguration { IsValid: true } configurationPlugin) + { + var issues = string.Join(" ", plugin.Issues); + plugin.Dispose(); + throw new InvalidOperationException($"The code of this test did not produce a valid configuration plugin: {issues}"); + } + + await configurationPlugin.InitializeAsync(false); + return configurationPlugin; + } + + /// + /// Writes a value which the setting accepts, in the Lua notation of a configuration plugin. + /// + /// + /// Numbers take the default of the setting, because only the default is sure to pass the range + /// a setting may check. Every collection accepts an empty table. + /// + private static string ToLuaValue(ConfigMetaBase setting) + { + var valueType = GetValueType(setting); + var defaultValue = setting.GetType().GetProperty(nameof(ConfigMeta.Default))!.GetValue(setting); + + if (valueType == typeof(bool)) + return "true"; + + if (valueType == typeof(string)) + return $"\"{SAMPLE_TEXT}\""; + + if (valueType.IsEnum) + return $"\"{Enum.GetName(valueType, defaultValue!)}\""; + + if (valueType == typeof(int)) + return Convert.ToString(defaultValue, CultureInfo.InvariantCulture)!; + + if (typeof(IEnumerable).IsAssignableFrom(valueType)) + return "{}"; + + throw new InvalidOperationException($"This test has no value for the setting '{setting.SettingName}' of type {valueType}. Extend ToLuaValue with a value which PluginConfiguration accepts for that type."); + } + + /// + /// Determines the type a setting holds. + /// + private static Type GetValueType(ConfigMetaBase setting) + { + for (var type = setting.GetType(); type is not null; type = type.BaseType) + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ConfigMeta<,>)) + return type.GetGenericArguments()[1]; + + throw new InvalidOperationException($"The setting '{setting.SettingName}' is no ConfigMeta, so this test cannot tell which type it holds."); + } + + /// + /// Checks whether the plugin of this test manages a setting: by locking it, or by contributing to it. + /// + private static bool IsManagedByThePlugin(ConfigMetaBase setting) => (setting.IsLocked && setting.LockedByConfigPluginId == PLUGIN_ID) || setting.ContributingConfigPluginIds.Contains(PLUGIN_ID); +} \ No newline at end of file