diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 97ed293f..1bc12d75 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5551,6 +5551,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Accept the ter -- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept GPL and archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking \"Accept GPL and archive,\" you agree to the terms of the GPL license. Software under GPL is free of charge and free to use." +-- {0} profiles +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1238255445"] = "{0} profiles" + -- Install plugin UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Install plugin" @@ -5566,6 +5569,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "You are -- Authors UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Authors" +-- Data source +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2034620186"] = "Data source" + +-- A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up: +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2051328106"] = "A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:" + -- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source." @@ -5575,12 +5584,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "You are -- You are about to install a configuration plugin from a file. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "You are about to install a configuration plugin from a file." +-- {0} introductions on the welcome page +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2107991661"] = "{0} introductions on the welcome page" + -- You are about to install a theme plugin from a file. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "You are about to install a theme plugin from a file." +-- {0} profile +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2342765572"] = "{0} profile" + +-- {0} introduction on the welcome page +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2426110502"] = "{0} introduction on the welcome page" + -- Support contact UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Support contact" +-- Name +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T266367750"] = "Name" + +-- {0} setting it takes control of +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2868009192"] = "{0} setting it takes control of" + +-- {0} settings it takes control of +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3190775003"] = "{0} settings it takes control of" + +-- {0} chat templates +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3235448458"] = "{0} chat templates" + +-- {0} document analysis policy +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3278137746"] = "{0} document analysis policy" + -- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}." @@ -5590,18 +5623,45 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unknown -- Type UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Type" +-- {0} mandatory information you have to accept before using AI Studio +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3540986519"] = "{0} mandatory information you have to accept before using AI Studio" + +-- Transcription provider +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3566003684"] = "Transcription provider" + -- Replace plugin UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Replace plugin" +-- LLM provider +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4099016901"] = "LLM provider" + +-- {0} chat template +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4147879421"] = "{0} chat template" + +-- {0} document analysis policies +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T449490978"] = "{0} document analysis policies" + -- The authors marked this plugin as deprecated: {0} UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}" +-- It also brings: +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T713968030"] = "It also brings:" + -- You are about to install a plugin from a file. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "You are about to install a plugin from a file." +-- Embedding provider +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T877326195"] = "Embedding provider" + -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Cancel" +-- Sends data to +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T914647109"] = "Sends data to" + +-- Destination +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Destination" + -- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally." @@ -8059,18 +8119,30 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Disable plugin" -- Import UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Import" +-- Import plugin +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1467093263"] = "Import plugin" + -- Assistant Audit UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistant Audit" -- Internal Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins" +-- Plugin updated. +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1646565893"] = "Plugin updated." + +-- Import plugin from a file +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T169921408"] = "Import plugin from a file" + -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" -- Edit assistant plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1851885496"] = "Edit assistant plugin" +-- Plugin installed. +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1889482678"] = "Plugin installed." + -- Send a mail UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" @@ -8080,9 +8152,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" -- No source url available UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available" --- Assistant installed. -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistant installed." - -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" @@ -8104,9 +8173,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin -- An error occurred while sharing the plugin. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin." --- Import assistant plugin -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Import assistant plugin" - -- Your organization has disabled exporting plugins. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins." @@ -8137,9 +8203,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Install Plugin" -- Please drop only one plugin archive at a time. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Please drop only one plugin archive at a time." --- Assistant updated. -UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistant updated." - -- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic security audit for the assistant plugin '{0}' failed. Please run it manually." @@ -9736,6 +9799,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1084244321"] -- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins." +-- The imported plugin uses the ID of another installed plugin. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1195382910"] = "The imported plugin uses the ID of another installed plugin." + -- The assistant plugin directory is outside the local assistant plugin directory. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory." @@ -9748,9 +9814,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1288328479"] -- The assistant cannot be deleted while background work is still running. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running." --- Currently, only assistant plugins can be imported. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T139615196"] = "Currently, only assistant plugins can be imported." - -- The resolved plugin directory is outside the plugin directory. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1559620698"] = "The resolved plugin directory is outside the plugin directory." @@ -9766,6 +9829,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1821013825"] -- No Lua plugin code was generated. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated." +-- Your organization has disabled importing configuration plugins. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2134532120"] = "Your organization has disabled importing configuration plugins." + -- The assistant plugin directory does not exist. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist." @@ -9784,27 +9850,30 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2682912892"] -- The AI Studio data directory is not initialized yet. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet." --- The imported assistant plugin is invalid. Issue: {0} -UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2777304537"] = "The imported assistant plugin is invalid. Issue: {0}" - -- Only assistant plugins can be deleted. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2864597027"] = "Only assistant plugins can be deleted." -- The generated plugin is not an assistant plugin. Issue: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}" --- The imported assistant plugin uses the ID of another installed plugin. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T2971411166"] = "The imported assistant plugin uses the ID of another installed plugin." - -- The configuration plugin directory does not exist. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3073748821"] = "The configuration plugin directory does not exist." +-- Only assistant and configuration plugins can be imported. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3166787637"] = "Only assistant and configuration plugins can be imported." + -- Your organization has disabled importing plugins. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins." -- The plugin archive must contain exactly one plugin.lua file. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file." +-- Your organization deployed a configuration with the same ID. An imported configuration must not take its place. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T352004699"] = "Your organization deployed a configuration with the same ID. An imported configuration must not take its place." + +-- The imported plugin is invalid. Issue: {0} +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3634046009"] = "The imported plugin is invalid. Issue: {0}" + -- Config Server managed assistant plugins cannot be deleted. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGININSTALLSERVICE::T3751820312"] = "Config Server managed assistant plugins cannot be deleted." diff --git a/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor b/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor index a66315b0..3bb882cc 100644 --- a/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor +++ b/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor @@ -35,6 +35,51 @@ } + @if (this.Preview.ConfigurationSummary is { HasAnyContent: true } configurationSummary) + { + + @T("A configuration takes effect right after the installation and has no on/off switch. Please check what it sets up:") + + + @if (configurationSummary.Destinations.Count > 0) + { + + + + @T("Sends data to") + @T("Name") + @T("Destination") + + + + @foreach (var destination in configurationSummary.Destinations) + { + + @this.DestinationTypeLabel(destination.Type) + @destination.Name + @destination.Endpoint + + } + + + } + + @if (this.ConfigurationContents.Count > 0) + { + + @T("It also brings:") + + + @foreach (var content in this.ConfigurationContents) + { + + @content + + } + + } + } + @if (!string.IsNullOrWhiteSpace(this.Preview.Plugin.DeprecationMessage)) { diff --git a/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor.cs b/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor.cs index b94f78cc..03f7c251 100644 --- a/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/PluginImportDialog.razor.cs @@ -41,6 +41,48 @@ public partial class PluginImportDialog : MSGComponentBase ? string.Join(", ", this.Preview.Plugin.Authors) : this.T("Unknown"); + /// + /// Names the kind of a destination a configuration plugin brings. + /// + private string DestinationTypeLabel(PluginConfigurationObjectType objectType) => objectType switch + { + PluginConfigurationObjectType.LLM_PROVIDER => this.T("LLM provider"), + PluginConfigurationObjectType.EMBEDDING_PROVIDER => this.T("Embedding provider"), + PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => this.T("Transcription provider"), + PluginConfigurationObjectType.DATA_SOURCE => this.T("Data source"), + + _ => this.T("Unknown"), + }; + + /// + /// Everything a configuration plugin brings besides its providers and data sources. Only what is + /// actually there gets listed, so the dialog stays short for a small configuration. + /// + private List ConfigurationContents + { + get + { + var contents = new List(); + if (this.Preview.ConfigurationSummary is not { } summary) + return contents; + + Add(summary.DeclaredSettings, this.T("{0} setting it takes control of"), this.T("{0} settings it takes control of")); + Add(summary.ChatTemplates, this.T("{0} chat template"), this.T("{0} chat templates")); + Add(summary.Profiles, this.T("{0} profile"), this.T("{0} profiles")); + Add(summary.DocumentAnalysisPolicies, this.T("{0} document analysis policy"), this.T("{0} document analysis policies")); + Add(summary.MandatoryInfos, this.T("{0} mandatory information you have to accept before using AI Studio"), this.T("{0} mandatory information you have to accept before using AI Studio")); + Add(summary.Introductions, this.T("{0} introduction on the welcome page"), this.T("{0} introductions on the welcome page")); + + return contents; + + void Add(int count, string singular, string plural) + { + if (count > 0) + contents.Add(string.Format(count == 1 ? singular : plural, count)); + } + } + } + private void Cancel() => this.MudDialog.Cancel(); private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true)); diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index dd56a119..6f24e5d5 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -11,7 +11,7 @@ public int Priority { get; } = ReadPriority(state); + + /// + /// How many settings this configuration plugin declares. + /// + /// + /// This counts the entries of the Lua SETTINGS table, without the .AllowUserOverride + /// companions. We need it for the import preview: a dry run does not lock anything, so the + /// number of settings the plugin would take over cannot be read from the managed configuration + /// at that point. + /// + public int DeclaredSettingsCount { get; private set; } public async Task InitializeAsync(bool dryRun) { @@ -148,6 +159,26 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT return 0; } + /// + /// Counts the settings a configuration plugin declares, ignoring the .AllowUserOverride + /// companion keys: those refine a setting instead of adding one. + /// + private static int CountDeclaredSettings(LuaTable settingsTable) + { + const string USER_OVERRIDE_SUFFIX = ".AllowUserOverride"; + + var count = 0; + var previousKey = LuaValue.Nil; + while (settingsTable.TryGetNext(previousKey, out var pair)) + { + previousKey = pair.Key; + if (pair.Key.TryRead(out var settingName) && !settingName.EndsWith(USER_OVERRIDE_SUFFIX, StringComparison.Ordinal)) + count++; + } + + return count; + } + /// /// Tries to initialize the UI text content of the plugin. /// @@ -173,6 +204,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT message = TB("The SETTINGS table does not exist or is not a valid table."); return false; } + + this.DeclaredSettingsCount = CountDeclaredSettings(settingsTable); // Config: check for updates, and if so, how often? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 18eedfb2..ded8d201 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -34,6 +34,41 @@ public sealed record PluginConfigurationObject /// public required PluginConfigurationObjectType Type { get; init; } = PluginConfigurationObjectType.NONE; + /// + /// The name of the configuration object, e.g. the name of a provider. + /// + public string Name { get; init; } = string.Empty; + + /// + /// Where this configuration object sends data to: the host of a self-hosted provider or data + /// source, or the name of the cloud provider. Empty for objects without a destination, such as + /// chat templates or profiles. + /// + /// + /// We keep this next to the object metadata so the import preview can tell users where a + /// configuration would send their prompts before its providers are stored. + /// + public string Endpoint { get; private init; } = string.Empty; + + /// + /// Determines the destination of a configuration object for the import preview. + /// + private static string DescribeEndpoint(IConfigurationObject configObject) => configObject switch + { + Settings.Provider { IsSelfHosted: true } provider => provider.Hostname, + Settings.Provider provider => Provider.LLMProvidersExtensions.ToName(provider.UsedLLMProvider), + + EmbeddingProvider { IsSelfHosted: true } embeddingProvider => embeddingProvider.Hostname, + EmbeddingProvider embeddingProvider => Provider.LLMProvidersExtensions.ToName(embeddingProvider.UsedLLMProvider), + + TranscriptionProvider { IsSelfHosted: true } transcriptionProvider => transcriptionProvider.Hostname, + TranscriptionProvider transcriptionProvider => Provider.LLMProvidersExtensions.ToName(transcriptionProvider.UsedLLMProvider), + + DataSourceERI_V1 dataSource => dataSource.Hostname, + + _ => string.Empty, + }; + /// /// Parses Lua table entries into configuration objects of the specified type, populating the /// provided list with results. @@ -125,6 +160,8 @@ public sealed record PluginConfigurationObject ConfigPluginId = configPluginId, Id = Guid.Parse(configObject.Id), Type = configObjectType, + Name = configObject.Name, + Endpoint = DescribeEndpoint(configObject), }); if (dryRun) @@ -214,6 +251,8 @@ public sealed record PluginConfigurationObject ConfigPluginId = configPluginId, Id = Guid.Parse(configObject.Id), Type = PluginConfigurationObjectType.DATA_SOURCE, + Name = configObject.Name, + Endpoint = DescribeEndpoint(configObject), }); if (dryRun) diff --git a/app/MindWork AI Studio/Tools/Services/ConfigurationPluginDestination.cs b/app/MindWork AI Studio/Tools/Services/ConfigurationPluginDestination.cs new file mode 100644 index 00000000..04fdd979 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/ConfigurationPluginDestination.cs @@ -0,0 +1,11 @@ +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tools.Services; + +/// +/// A provider or data source a configuration plugin brings, and where it sends data to. +/// +/// The kind of configuration object. +/// The name the configuration gives it. +/// The host of a self-hosted destination, or the name of the cloud provider. +public sealed record ConfigurationPluginDestination(PluginConfigurationObjectType Type, string Name, string Endpoint); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/ConfigurationPluginImportSummary.cs b/app/MindWork AI Studio/Tools/Services/ConfigurationPluginImportSummary.cs new file mode 100644 index 00000000..84ef433b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/ConfigurationPluginImportSummary.cs @@ -0,0 +1,38 @@ +namespace AIStudio.Tools.Services; + +/// +/// What a configuration plugin would set up, read from the archive before anything is installed. +/// +/// +/// A configuration takes effect the moment it is installed, and it has no on/off switch. The import +/// dialog is therefore the only place where users can see what they are about to accept, which is +/// why this carries the destinations of providers and data sources and not just their number. +/// +/// The providers and data sources, together with where they send data to. +/// How many chat templates the configuration adds. +/// How many profiles the configuration adds. +/// How many document analysis policies the configuration adds. +/// How many settings the configuration takes over. +/// How many mandatory information texts users must accept. +/// How many introductions the configuration adds to the welcome page. +public sealed record ConfigurationPluginImportSummary( + IReadOnlyList Destinations, + int ChatTemplates, + int Profiles, + int DocumentAnalysisPolicies, + int DeclaredSettings, + int MandatoryInfos, + int Introductions) +{ + /// + /// True when the configuration sets up anything at all. + /// + public bool HasAnyContent => + this.Destinations.Count > 0 || + this.ChatTemplates > 0 || + this.Profiles > 0 || + this.DocumentAnalysisPolicies > 0 || + this.DeclaredSettings > 0 || + this.MandatoryInfos > 0 || + this.Introductions > 0; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginImportPreview.cs b/app/MindWork AI Studio/Tools/Services/PluginImportPreview.cs index 0fe7d02e..3891dc59 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginImportPreview.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginImportPreview.cs @@ -8,7 +8,10 @@ namespace AIStudio.Tools.Services; /// /// The plugin from the archive, with the metadata it declares about itself. /// The installed plugin that gets replaced or null when the archive adds a new plugin. -public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin) +/// +/// What a configuration plugin would set up. Null for every other plugin type. +/// +public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin, ConfigurationPluginImportSummary? ConfigurationSummary = null) { /// /// True when an installed plugin with the same ID gets replaced. diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs index 99db6d98..8fb327f8 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.AssistantBuilder.cs @@ -95,7 +95,7 @@ public sealed partial class PluginInstallService var validation = await ValidatePluginCodeAsync( stagingDirectory, pluginCode, - PluginType.ASSISTANT, + [PluginType.ASSISTANT], TB("The generated plugin is not an assistant plugin. Issue: {0}"), TB("The generated assistant plugin is invalid. Issue: {0}"), TB("The generated assistant plugin uses the ID of another installed plugin."), diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs index 937fe2f9..6824a4f3 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Editing.cs @@ -161,7 +161,7 @@ public sealed partial class PluginInstallService return await ValidatePluginCodeAsync( pluginDirectory, lua.Trim(), - PluginType.ASSISTANT, + [PluginType.ASSISTANT], TB("The edited plugin is not an assistant plugin. Issue: {0}"), TB("The edited assistant plugin is invalid. Issue: {0}"), TB("The edited assistant plugin uses the ID of another installed plugin."), diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs index 1cf99c2c..5e4651fc 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs @@ -1,5 +1,6 @@ using System.Text; using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; @@ -7,7 +8,12 @@ namespace AIStudio.Tools.Services; public sealed partial class PluginInstallService { /// - /// Installs an assistant plugin archive that contains exactly one plugin.lua file. + /// The plugin types a user may import from an archive. + /// + private static readonly PluginType[] IMPORTABLE_PLUGIN_TYPES = [PluginType.ASSISTANT, PluginType.CONFIGURATION]; + + /// + /// Installs a plugin archive that contains exactly one plugin.lua file. /// Companion files are validated from and moved with the same staging directory. /// /// The local .mwplugin or .zip archive path. @@ -28,14 +34,11 @@ public sealed partial class PluginInstallService if (!File.Exists(archivePath)) return Error(TB("The selected plugin archive does not exist.")); - if (!TryGetPluginRoot(PluginType.ASSISTANT, out var assistantPluginsRoot, out var rootIssue)) - return Error(rootIssue); - if (!PluginFactory.IsInitialized) return Error(TB("The plugin system is not initialized yet.")); await this.installSemaphore.WaitAsync(token); - var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}"); + var stagingDirectory = Path.Join(Path.GetTempPath(), $"plugin-import.staging-{Guid.NewGuid():N}"); try { token.ThrowIfCancellationRequested(); @@ -51,38 +54,42 @@ public sealed partial class PluginInstallService var validation = await ValidatePluginCodeAsync( pluginDirectory, pluginCode.Trim(), - PluginType.ASSISTANT, - TB("Currently, only assistant plugins can be imported."), - TB("The imported assistant plugin is invalid. Issue: {0}"), - TB("The imported assistant plugin uses the ID of another installed plugin."), + IMPORTABLE_PLUGIN_TYPES, + TB("Only assistant and configuration plugins can be imported."), + TB("The imported plugin is invalid. Issue: {0}"), + TB("The imported plugin uses the ID of another installed plugin."), token); - if (!validation.Success || validation.AssistantPlugin is null) + if (!validation.Success || validation.Plugin is null) return Error(validation.Issue); - // A plugin the user imports by hand never comes from a config server. We reject such - // archives because AI Studio trusts this self-declared flag: an imported plugin - // claiming it would be neither replaceable nor deletable through the user interface: - if (validation.AssistantPlugin.IsManagedByConfigServer) - return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.")); + var plugin = validation.Plugin; + var eligibilityIssue = this.GetImportEligibilityIssue(plugin); + if (!string.IsNullOrEmpty(eligibilityIssue)) + return Error(eligibilityIssue); // The archive would replace an existing plugin: reject it when that plugin belongs // to the IT department. We check this before asking the user, so that the // confirmation never offers something we would refuse afterwards anyway: - var replacementIssue = GetReplacementIssue(validation.AssistantPlugin.Id, PluginType.ASSISTANT); + var replacementIssue = GetReplacementIssue(plugin.Id, plugin.Type); if (!string.IsNullOrEmpty(replacementIssue)) return Error(replacementIssue); + // Local plugins live in the directory of their type, never in the enterprise + // configuration directory. Only a config server deploys plugins there: + if (!TryGetPluginRoot(plugin.Type, out var pluginRoot, out var rootIssue)) + return Error(rootIssue); + // Everything is validated, but nothing was written yet. This is the point where the // user decides, because the plugin code comes from an untrusted source: - if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin))) + if (!await confirmAsync(CreateImportPreview(plugin))) return CancelledByUser(); - return await this.InstallStagedPluginAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, PluginType.ASSISTANT, token); + return await this.InstallStagedPluginAsync(pluginRoot, validation with { StagingDirectory = pluginDirectory }, plugin.Type, token); } catch (Exception e) when (e is not OperationCanceledException) { - this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath); + this.logger.LogError(e, "Failed to extract or validate plugin archive '{ArchivePath}'.", archivePath); return Error(string.Format(TB("Unexpected error: {0}"), e.Message)); } finally @@ -91,4 +98,45 @@ public sealed partial class PluginInstallService this.installSemaphore.Release(); } } + + /// + /// Checks the rules that depend on the type of the plugin inside the archive. + /// + /// The validated plugin from the archive. + /// A user-facing issue when the archive must not be installed, an empty string otherwise. + private string GetImportEligibilityIssue(PluginBase plugin) => plugin switch + { + // A plugin the user imports by hand never comes from a config server. We reject such + // archives because AI Studio trusts this self-declared flag: an imported plugin claiming it + // would be neither replaceable nor deletable through the user interface: + PluginAssistants { IsManagedByConfigServer: true } => TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."), + + PluginConfiguration configurationPlugin => this.GetConfigurationImportEligibilityIssue(configurationPlugin), + + _ => string.Empty, + }; + + /// + /// Checks the additional rules for importing a configuration plugin. + /// + /// + /// A configuration takes effect immediately and has no on/off switch, so it gets its own + /// organization permission on top of the general import permission. + /// + private string GetConfigurationImportEligibilityIssue(PluginConfiguration configurationPlugin) + { + if (!this.settingsManager.ConfigurationData.App.AllowUserToImportConfigurationPlugins) + return TB("Your organization has disabled importing configuration plugins."); + + if (configurationPlugin.DeployedUsingConfigServer is true) + return TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."); + + // Never let an imported configuration take the place of one the organization deployed. This + // also covers a deployed configuration which currently cannot be loaded, e.g. because of an + // error in its Lua code: + if (PluginFactory.IsEnterpriseConfigurationPlugin(configurationPlugin.Id)) + return TB("Your organization deployed a configuration with the same ID. An imported configuration must not take its place."); + + return string.Empty; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs index 8912837f..d6862f31 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Installation.cs @@ -85,20 +85,20 @@ public sealed partial class PluginInstallService /// /// The staging directory the plugin currently lives in. /// The plugin.lua content to validate. - /// The plugin type the caller accepts. + /// The plugin types the caller accepts. /// Issue when the plugin has another type. Gets the plugin issues as {0}. - /// Issue when the plugin is of the right type, but invalid. Gets the plugin issues as {0}. + /// Issue when the plugin is of an accepted type, but invalid. Gets the plugin issues as {0}. /// Issue when another plugin already uses this plugin ID. /// Cancellation token for running the Lua code. /// The validation result, including the loaded plugin when it passed. - private static async Task ValidatePluginCodeAsync(string pluginDirectory, string pluginCode, PluginType expectedType, + private static async Task ValidatePluginCodeAsync(string pluginDirectory, string pluginCode, IReadOnlyCollection acceptedTypes, string wrongTypeIssue, string invalidPluginIssue, string conflictingPluginIdIssue, CancellationToken token) { // The plugin is not installed yet: it sits in a staging directory outside the installed // plugins directory. We allow that directory as the module base, so the plugin can load its // own Lua modules, e.g., an icon.lua, while we validate it: var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory); - if (plugin.Type != expectedType) + if (!acceptedTypes.Contains(plugin.Type)) return PluginValidationResult.Failure(string.Format(wrongTypeIssue, string.Join("; ", plugin.Issues))); if (!plugin.IsValid) @@ -108,7 +108,7 @@ public sealed partial class PluginInstallService // ID alone, e.g., the base language plugin in PluginFactory.Starting. A plugin carrying the // ID of a plugin of another type would break those lookups. Reusing the ID of another local // plugin of the same type stays allowed: that is how updating one works. - if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == plugin.Id && (availablePlugin.IsInternal || availablePlugin.Type != expectedType))) + if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == plugin.Id && (availablePlugin.IsInternal || availablePlugin.Type != plugin.Type))) return PluginValidationResult.Failure(conflictingPluginIdIssue); return new(true, string.Empty, plugin, string.Empty); @@ -157,7 +157,41 @@ public sealed partial class PluginInstallService /// /// The validated plugin from the archive. /// The preview shown to the user before the installation starts. - private static PluginImportPreview CreateImportPreview(PluginBase plugin) => new(plugin, FindReplaceablePlugin(plugin.Id, plugin.Type)); + private static PluginImportPreview CreateImportPreview(PluginBase plugin) => new( + plugin, + FindReplaceablePlugin(plugin.Id, plugin.Type), + plugin is PluginConfiguration configurationPlugin ? CreateConfigurationImportSummary(configurationPlugin) : null); + + /// + /// Collects what a configuration plugin would set up once it is installed. + /// + /// + /// The plugin was loaded as a dry run, so nothing of this is stored yet. The destinations come + /// from the parsed configuration objects, which is why the preview can name the host a provider + /// would talk to. + /// + private static ConfigurationPluginImportSummary CreateConfigurationImportSummary(PluginConfiguration configurationPlugin) + { + var configObjects = configurationPlugin.ConfigObjects.ToList(); + var destinations = configObjects + .Where(configObject => configObject.Type is PluginConfigurationObjectType.LLM_PROVIDER + or PluginConfigurationObjectType.EMBEDDING_PROVIDER + or PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER + or PluginConfigurationObjectType.DATA_SOURCE) + .Select(configObject => new ConfigurationPluginDestination(configObject.Type, configObject.Name, configObject.Endpoint)) + .ToList(); + + return new( + Destinations: destinations, + ChatTemplates: CountObjects(PluginConfigurationObjectType.CHAT_TEMPLATE), + Profiles: CountObjects(PluginConfigurationObjectType.PROFILE), + DocumentAnalysisPolicies: CountObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), + DeclaredSettings: configurationPlugin.DeclaredSettingsCount, + MandatoryInfos: configurationPlugin.MandatoryInfos.Count, + Introductions: configurationPlugin.Introductions.Count); + + int CountObjects(PluginConfigurationObjectType type) => configObjects.Count(configObject => configObject.Type == type); + } /// /// Checks whether an installation may replace the plugin that currently uses the given ID. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index bcda99c4..83a7927c 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -6,6 +6,7 @@ - Added the option to import plugins by dropping a plugin archive onto the plugin page. - Added the dedicated file extension `.mwplugin` for plugin archives. - Added an option for organizations to disable importing, sharing, and exporting plugins. +- Added the option to import configuration plugins from a file. Before installing, AI Studio shows what the configuration sets up: which LLM providers and data sources it adds and where each of them sends your data, plus how many settings it takes control of. A configuration takes effect right away and has no on/off switch, so please install one only when you trust its source. You can remove it again at any time. - Added a separate option for organizations to disable importing configuration plugins. Organizations can now let people import assistants while keeping configurations to their IT department. - Added a delete button for configuration plugins you placed yourself. Until now, such a plugin could only be removed from the data directory by hand, because configuration plugins have no on/off switch. Before deleting, AI Studio lists what disappears with it, such as providers, data sources, and settings that return to their default. Configurations deployed by your IT department cannot be deleted. - Added a priority for configuration plugins. Organizations that deploy several configurations can now decide which one wins: a configuration with a higher priority overrides the settings and providers of a lower one. This allows a company-wide base configuration that each department refines for itself. @@ -19,4 +20,4 @@ - Fixed approvals for assistant plugins being accepted from any configuration plugin. An approval marks an assistant as safe without a security check, and the app states that your organization approved it. Only configurations your IT department deploys can do that now; approvals from a locally placed configuration plugin are ignored and reported in the log. - Fixed preview features contributed by several configuration plugins at once. Only the most recent contribution was recognized as coming from your organization, so features enabled by another configuration looked as if you had switched them on yourself. Each configuration is now tracked separately, which lets your organization enable one preview feature company-wide and another one for a single department. - Fixed which configuration wins when two configuration plugins collide, e.g. by claiming the same plugin ID, by managing the same setting, or by defining the same provider. Previously, this was down to chance, so a local configuration plugin could take over parts of the configuration your IT department deployed. Configurations from your organization now always win, and every ignored attempt is reported in the log. -- Upgraded dependencies to their latest versions to improve security and stability. +- Upgraded dependencies to their latest versions to improve security and stability. \ No newline at end of file