diff --git a/app/MindWork AI Studio/Components/ConfigInfoRow.razor b/app/MindWork AI Studio/Components/ConfigInfoRow.razor new file mode 100644 index 00000000..829f7ba5 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRow.razor @@ -0,0 +1,10 @@ +
+ + + @this.Item.Text + + @if (!string.IsNullOrWhiteSpace(this.Item.CopyValue)) + { + + } +
diff --git a/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs b/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs new file mode 100644 index 00000000..0a2de099 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class ConfigInfoRow : ComponentBase +{ + [Parameter] + public ConfigInfoRowItem Item { get; set; } = new(Icons.Material.Filled.ArrowRightAlt, string.Empty, string.Empty, string.Empty, string.Empty); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs b/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs new file mode 100644 index 00000000..ab700c53 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Components; + +public sealed record ConfigInfoRowItem( + string Icon, + string Text, + string CopyValue, + string CopyTooltip, + string Style = "" +); \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor new file mode 100644 index 00000000..4a3c8106 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor @@ -0,0 +1,21 @@ + +
+ + + @this.HeaderText + +
+ + @foreach (var item in this.Items) + { + + } + + @if (this.ShowWarning) + { +
+ + @this.WarningText +
+ } +
diff --git a/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs new file mode 100644 index 00000000..2fc224be --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class ConfigPluginInfoCard : ComponentBase +{ + [Parameter] + public string HeaderIcon { get; set; } = Icons.Material.Filled.Extension; + + [Parameter] + public string HeaderText { get; set; } = string.Empty; + + [Parameter] + public IEnumerable Items { get; set; } = []; + + [Parameter] + public bool ShowWarning { get; set; } + + [Parameter] + public string WarningText { get; set; } = string.Empty; + + [Parameter] + public string Class { get; set; } = "pa-3 mt-2 mb-2"; +} diff --git a/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor new file mode 100644 index 00000000..e05f9539 --- /dev/null +++ b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor @@ -0,0 +1,15 @@ + +
+ + @if (this.IsConfigured) + { + + @this.ConfiguredText + } + else + { + + @this.NotConfiguredText + } +
+
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs new file mode 100644 index 00000000..5fa1a5dd --- /dev/null +++ b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class EncryptionSecretInfo : ComponentBase +{ + [Parameter] + public bool IsConfigured { get; set; } + + [Parameter] + public string ConfiguredText { get; set; } = string.Empty; + + [Parameter] + public string NotConfiguredText { get; set; } = string.Empty; + + [Parameter] + public string Class { get; set; } = "mt-2 mb-2"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 08005e68..07dfebd2 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -215,8 +215,28 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan .CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) .Where(env => env != default) .ToList(); + + var failedDeferredConfigIds = new HashSet(); foreach (var env in enterpriseEnvironments) - await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); + { + var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); + if (!wasDownloadSuccessful) + { + failedDeferredConfigIds.Add(env.ConfigurationId); + this.Logger.LogWarning("Failed to download deferred enterprise configuration '{ConfigId}' during startup. Keeping managed plugins unchanged.", env.ConfigurationId); + } + } + + if (EnterpriseEnvironmentService.HasValidEnterpriseSnapshot) + { + var activeConfigIds = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS + .Select(env => env.ConfigurationId) + .ToHashSet(); + + PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(activeConfigIds); + if (failedDeferredConfigIds.Count > 0) + this.Logger.LogWarning("Deferred startup updates failed for {FailedCount} enterprise configuration(s). Those configurations were kept unchanged.", failedDeferredConfigIds.Count); + } // Initialize the enterprise encryption service for decrypting API keys: await PluginFactory.InitializeEnterpriseEncryption(this.RustService); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 5a964179..435a6a56 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -64,33 +64,19 @@ @foreach (var plug in this.configPlugins) { - -
- - @plug.Name -
-
- - @T("Configuration plugin ID:") @plug.Id - -
-
+ } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; @@ -101,97 +87,91 @@ @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) { - -
- - @T("Waiting for the configuration plugin...") -
-
- - @T("Enterprise configuration ID:") @env.ConfigurationId - -
-
- - @T("Configuration server:") @env.ConfigurationServerUrl - -
-
+ } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; case true: - - @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") - + @if (this.HasAnyLoadedEnterpriseConfigurationPlugin) + { + + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") + + } + else + { + + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.") + + } @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) { - var matchingPlugin = this.configPlugins.FirstOrDefault(p => p.Id == env.ConfigurationId); - -
- @if (matchingPlugin is not null) - { - - @matchingPlugin.Name - } - else - { - - @T("ID mismatch: the plugin ID differs from the enterprise configuration ID.") - } -
-
- - @T("Enterprise configuration ID:") @env.ConfigurationId - -
-
- - @T("Configuration server:") @env.ConfigurationServerUrl - -
- @if (matchingPlugin is not null) - { -
- - @T("Configuration plugin ID:") @matchingPlugin.Id - -
- } -
+ var matchingPlugin = this.FindManagedConfigurationPlugin(env.ConfigurationId); + if (matchingPlugin is null) + { + + continue; + } + + } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; } diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index a4eb5123..2027285f 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -69,13 +69,20 @@ public partial class Information : MSGComponentBase private bool showDatabaseDetails; - private List configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); + private List configPlugins = PluginFactory.AvailablePlugins + .Where(x => x.Type is PluginType.CONFIGURATION) + .OfType() + .ToList(); private sealed record DatabaseDisplayInfo(string Label, string Value); private readonly List databaseDisplayInfo = new(); private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive); + + private bool HasAnyLoadedEnterpriseConfigurationPlugin => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS + .Where(e => e.IsActive) + .Any(env => this.FindManagedConfigurationPlugin(env.ConfigurationId) is not null); /// /// Determines whether the enterprise configuration has details that can be shown/hidden. @@ -130,7 +137,10 @@ public partial class Information : MSGComponentBase switch (triggeredEvent) { case Event.PLUGINS_RELOADED: - this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); + this.configPlugins = PluginFactory.AvailablePlugins + .Where(x => x.Type is PluginType.CONFIGURATION) + .OfType() + .ToList(); await this.InvokeAsync(this.StateHasChanged); break; } @@ -196,6 +206,18 @@ public partial class Information : MSGComponentBase this.showDatabaseDetails = !this.showDatabaseDetails; } + private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId) + { + return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId) + // Backward compatibility for already downloaded plugins without ManagedConfigurationId. + ?? this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId is null && plugin.Id == configurationId); + } + + private bool IsManagedConfigurationIdMismatch(IAvailablePlugin plugin, Guid configurationId) + { + return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId; + } + private async Task CopyStartupLogPath() { await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath); diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 4c37375a..6fa2a8c9 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -24,6 +24,9 @@ VERSION = "1.0.0" -- The type of the plugin: TYPE = "CONFIGURATION" +-- True when this plugin is deployed by an enterprise configuration server: +DEPLOYED_USING_CONFIG_SERVER = false + -- The authors of the plugin: AUTHORS = {""} diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index 59909b25..d5a6f20a 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -56,43 +56,43 @@ public sealed record EmbeddingProvider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } @@ -114,7 +114,7 @@ public sealed record EmbeddingProvider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -128,31 +128,31 @@ public sealed record EmbeddingProvider( name, decryptedApiKey, SecretStoreType.EMBEDDING_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 2990655a..a2a0a0d3 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -94,31 +94,31 @@ public sealed record Provider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("InstanceName", out var instanceNameValue) || !instanceNameValue.TryRead(out var instanceName)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } @@ -127,27 +127,27 @@ public sealed record Provider( { if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})"); hfInferenceProvider = HFInferenceProvider.NONE; } } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead(out var additionalJsonApiParameters)) { // In this case, no reason exists to reject this provider, though. - LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters."); + LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters. (Plugin ID: {configPluginId})"); additionalJsonApiParameters = string.Empty; } @@ -171,7 +171,7 @@ public sealed record Provider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -185,31 +185,31 @@ public sealed record Provider( instanceName, decryptedApiKey, SecretStoreType.LLM_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index c4acf865..4c6ca871 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -56,43 +56,43 @@ public sealed record TranscriptionProvider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } @@ -114,7 +114,7 @@ public sealed record TranscriptionProvider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -128,31 +128,31 @@ public sealed record TranscriptionProvider( name, decryptedApiKey, SecretStoreType.TRANSCRIPTION_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs index a992d303..d1221c0a 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs @@ -3,4 +3,8 @@ namespace AIStudio.Tools.PluginSystem; public interface IAvailablePlugin : IPluginMetadata { public string LocalPath { get; } + + public bool IsManagedByConfigServer { get; } + + public Guid? ManagedConfigurationId { get; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index a8e10d5d..d28064e0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -17,6 +17,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT /// The list of configuration objects. Configuration objects are, e.g., providers or chat templates. /// public IEnumerable ConfigObjects => this.configObjects; + + /// + /// True/false when explicitly configured in the plugin, otherwise null. + /// + public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state); public async Task InitializeAsync(bool dryRun) { @@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT /// private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId; + private static bool? ReadDeployedUsingConfigServer(LuaState state) + { + if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead(out var deployedUsingConfigServer)) + return deployedUsingConfigServer; + + return null; + } + /// /// Tries to initialize the UI text content of the plugin. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index ffc6f5c0..d0b299d3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -79,13 +79,13 @@ public sealed record PluginConfigurationObject if (luaTableName is null) { - LOG.LogError($"The configuration object type '{configObjectType}' is not supported yet."); + LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId); return false; } if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead(out var luaTable)) { - LOG.LogWarning($"The {luaTableName} table does not exist or is not a valid table."); + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId); return false; } @@ -97,7 +97,7 @@ public sealed record PluginConfigurationObject var luaObjectTableValue = luaTable[i]; if (!luaObjectTableValue.TryRead(out var luaObjectTable)) { - LOG.LogWarning($"The {luaObjectTable} table at index {i} is not a valid table."); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId); continue; } @@ -151,12 +151,12 @@ public sealed record PluginConfigurationObject random ??= new ThreadSafeRandom(); configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; storedObjects.Add((TClass)configObject); - LOG.LogWarning($"The next number for the configuration object '{configObject.Name}' (id={configObject.Id}) could not be incremented. Using a random number instead."); + LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); } } } else - LOG.LogWarning($"The {luaObjectTable} table at index {i} does not contain a valid chat template configuration."); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid configuration object (type={ConfigObjectType}, config plugin id: {ConfigPluginId}).", luaTableName, i, configObjectType, configPluginId); } return true; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index e3923b65..9b56e3af 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { - public static async Task DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) + public static async Task<(bool Success, EntityTagHeaderValue? ETag, string? Issue)> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) { if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl)) - return null; + return (false, null, "Configuration ID or server URL is missing."); try { @@ -18,18 +18,24 @@ public static partial class PluginFactory using var http = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - return response.Headers.ETag; + if (!response.IsSuccessStatusCode) + { + LOG.LogError($"Failed to determine the ETag for configuration plugin '{configPlugId}'. HTTP Status: {response.StatusCode}"); + return (false, null, $"HTTP status: {response.StatusCode}"); + } + + return (true, response.Headers.ETag, null); } catch (Exception e) { LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin."); - return null; + return (false, null, e.Message); } } public static async Task TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) { - if(!IS_INITIALIZED) + if(!IsInitialized) { LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin."); return false; @@ -40,36 +46,72 @@ public static partial class PluginFactory LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})"); var tempDownloadFile = Path.GetTempFileName(); + var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}"); + string? backupDirectory = null; + var wasSuccessful = false; try { await LockHotReloadAsync(); using var httpClient = new HttpClient(); var response = await httpClient.GetAsync(downloadUrl, cancellationToken); - if (response.IsSuccessStatusCode) + if (!response.IsSuccessStatusCode) { - await using(var tempFileStream = File.Create(tempDownloadFile)) - { - await response.Content.CopyToAsync(tempFileStream, cancellationToken); - } - - var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); - if(Directory.Exists(configDirectory)) - Directory.Delete(configDirectory, true); - - Directory.CreateDirectory(configDirectory); - ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory); - - LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'."); - } - else LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}"); + return false; + } + + await using(var tempFileStream = File.Create(tempDownloadFile)) + { + await response.Content.CopyToAsync(tempFileStream, cancellationToken); + } + + ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory); + + var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); + if (Directory.Exists(configDirectory)) + { + backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}"); + Directory.Move(configDirectory, backupDirectory); + } + + Directory.Move(stagedDirectory, configDirectory); + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory)) + Directory.Delete(backupDirectory, true); + + LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'."); + wasSuccessful = true; } catch (Exception e) { LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin."); + + var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory)) + { + try + { + Directory.Move(backupDirectory, configDirectory); + } + catch (Exception restoreException) + { + LOG.LogError(restoreException, "Failed to restore the previous configuration plugin after a failed update."); + } + } } finally { + if (Directory.Exists(stagedDirectory)) + { + try + { + Directory.Delete(stagedDirectory, true); + } + catch (Exception e) + { + LOG.LogError(e, "Failed to delete the staged configuration plugin directory."); + } + } + if (File.Exists(tempDownloadFile)) { try @@ -85,6 +127,6 @@ public static partial class PluginFactory UnlockHotReload(); } - return true; + return wasSuccessful; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs index b98fa3c7..0505787c 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs @@ -6,7 +6,7 @@ public static partial class PluginFactory public static void SetUpHotReloading() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs index 42165bfb..b2cbe515 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs @@ -10,7 +10,7 @@ public static partial class PluginFactory { public static async Task EnsureInternalPlugins() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 9565a833..9fa39bde 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -30,7 +30,7 @@ public static partial class PluginFactory /// public static async Task LoadAll(CancellationToken cancellationToken = default) { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; @@ -104,16 +104,40 @@ public static partial class PluginFactory LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')"); - // For configuration plugins, validate that the plugin ID matches the enterprise config ID - // (the directory name under which the plugin was downloaded): - if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase)) + var isConfigurationPluginInConfigDirectory = + plugin.Type is PluginType.CONFIGURATION && + pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase); + + var isManagedByConfigServer = false; + Guid? managedConfigurationId = null; + if (plugin is PluginConfiguration configPlugin) { - var directoryName = Path.GetFileName(pluginPath); - if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id) - LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID."); + if (configPlugin.DeployedUsingConfigServer.HasValue) + isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value; + + else if (isConfigurationPluginInConfigDirectory) + { + isManagedByConfigServer = true; + LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'."); + } } - AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath)); + // For configuration plugins, validate that the plugin ID matches the enterprise config ID + // (the directory name under which the plugin was downloaded): + if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer) + { + var directoryName = Path.GetFileName(pluginPath); + if (Guid.TryParse(directoryName, out var enterpriseConfigId)) + { + managedConfigurationId = enterpriseConfigId; + if (enterpriseConfigId != plugin.Id) + LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID."); + } + else + LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID."); + } + + AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId)); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs index 9fa82a66..0a7b4a12 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs @@ -1,54 +1,129 @@ +using System.Text.RegularExpressions; + namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { - public static void RemovePluginAsync(Guid pluginId) + private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments"; + + public static void RemoveUnreferencedManagedConfigurationPlugins(ISet activeConfigurationIds) { - if (!IS_INITIALIZED) + if (!IsInitialized) return; - LOG.LogWarning($"Try to remove plugin with ID: {pluginId}"); + var pluginIdsToRemove = new HashSet(); + + // Case 1: Plugins are already loaded and metadata is available. + foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => + plugin.Type is PluginType.CONFIGURATION && + plugin.IsManagedByConfigServer && + !activeConfigurationIds.Contains(plugin.Id))) + pluginIdsToRemove.Add(plugin.Id); + + // Case 2: Startup cleanup before the initial plugin load. + // In this case, we inspect the .config directories directly. + if (Directory.Exists(CONFIGURATION_PLUGINS_ROOT)) + { + foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT)) + { + var directoryName = Path.GetFileName(pluginDirectory); + if (!Guid.TryParse(directoryName, out var pluginId)) + continue; + + if (activeConfigurationIds.Contains(pluginId)) + continue; + + var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory); + var isManagedByConfigServer = deployFlag ?? true; + if (!deployFlag.HasValue) + LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'."); + + if (isManagedByConfigServer) + pluginIdsToRemove.Add(pluginId); + } + } + + foreach (var pluginId in pluginIdsToRemove) + RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED); + } + + private static void RemovePluginAsync(Guid pluginId, string reason) + { + if (!IsInitialized) + return; + + LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason); // // Remove the plugin from the available plugins list: // var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId); - if (availablePluginToRemove == null) - { - LOG.LogWarning($"No plugin found with ID: {pluginId}"); - return; - } - - AVAILABLE_PLUGINS.Remove(availablePluginToRemove); + if (availablePluginToRemove != null) + AVAILABLE_PLUGINS.Remove(availablePluginToRemove); + else + LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason); // // Remove the plugin from the running plugins list: // var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId); if (runningPluginToRemove == null) - LOG.LogWarning($"No running plugin found with ID: {pluginId}"); + LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason); else RUNNING_PLUGINS.Remove(runningPluginToRemove); // // Delete the plugin directory: // - var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString()); - if (Directory.Exists(pluginDirectory)) - { - try - { - Directory.Delete(pluginDirectory, true); - LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully."); - } - catch (Exception ex) - { - LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'."); - } - } - else - LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist."); + DeleteConfigurationPluginDirectory(pluginId); - LOG.LogInformation($"Plugin with ID: {pluginId} removed successfully."); + LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason); } + + private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory) + { + try + { + var pluginFile = Path.Join(pluginDirectory, "plugin.lua"); + if (!File.Exists(pluginFile)) + return null; + + var pluginCode = File.ReadAllText(pluginFile); + var match = DeployedByConfigServerRegex().Match(pluginCode); + if (!match.Success) + return null; + + return bool.TryParse(match.Groups[1].Value, out var deployFlag) + ? deployFlag + : null; + } + catch (Exception ex) + { + LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'."); + return null; + } + } + + private static void DeleteConfigurationPluginDirectory(Guid pluginId) + { + var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString()); + if (!Directory.Exists(pluginDirectory)) + { + LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist."); + return; + } + + try + { + Directory.Delete(pluginDirectory, true); + LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully."); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'."); + } + } + + [GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex DeployedByConfigServerRegex(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs index 5d734b06..861dfce6 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs @@ -34,7 +34,7 @@ public static partial class PluginFactory if (startedBasePlugin is PluginLanguage languagePlugin) { - BASE_LANGUAGE_PLUGIN = languagePlugin; + BaseLanguage = languagePlugin; RUNNING_PLUGINS.Add(languagePlugin); LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'"); } @@ -44,7 +44,7 @@ public static partial class PluginFactory catch (Exception e) { LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'."); - BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE; + BaseLanguage = NoPluginLanguage.INSTANCE; } } @@ -106,8 +106,8 @@ public static partial class PluginFactory // // When this is a language plugin, we need to set the base language plugin. // - if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE) - languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN); + if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE) + languagePlugin.SetBaseLanguage(BaseLanguage); if(plugin is PluginConfiguration configPlugin) await configPlugin.InitializeAsync(false); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index 2c20ede0..5f7f0df0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -6,17 +6,17 @@ public static partial class PluginFactory { private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory)); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); - - private static bool IS_INITIALIZED; + private static string DATA_DIR = string.Empty; private static string PLUGINS_ROOT = string.Empty; private static string INTERNAL_PLUGINS_ROOT = string.Empty; private static string CONFIGURATION_PLUGINS_ROOT = string.Empty; private static string HOT_RELOAD_LOCK_FILE = string.Empty; private static FileSystemWatcher HOT_RELOAD_WATCHER = null!; - private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE; - public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN; + public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE; + + public static bool IsInitialized { get; private set; } /// /// Gets the enterprise encryption instance for decrypting API keys in configuration plugins. @@ -47,7 +47,7 @@ public static partial class PluginFactory /// public static bool Setup() { - if(IS_INITIALIZED) + if(IsInitialized) return false; LOG.LogInformation("Initializing plugin factory..."); @@ -61,14 +61,14 @@ public static partial class PluginFactory Directory.CreateDirectory(PLUGINS_ROOT); HOT_RELOAD_WATCHER = new(PLUGINS_ROOT); - IS_INITIALIZED = true; + IsInitialized = true; LOG.LogInformation("Plugin factory initialized successfully."); return true; } private static async Task LockHotReloadAsync() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized."); return; @@ -92,7 +92,7 @@ public static partial class PluginFactory private static void UnlockHotReload() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized."); return; @@ -113,7 +113,7 @@ public static partial class PluginFactory public static void Dispose() { - if(!IS_INITIALIZED) + if(!IsInitialized) return; HOT_RELOAD_WATCHER.Dispose(); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs index e98644cb..db07035a 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs @@ -1,6 +1,6 @@ namespace AIStudio.Tools.PluginSystem; -public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin +public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin { #region Implementation of IPluginMetadata @@ -51,6 +51,10 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail #region Implementation of IAvailablePlugin public string LocalPath { get; } = localPath; + + public bool IsManagedByConfigServer { get; } = isManagedByConfigServer; + + public Guid? ManagedConfigurationId { get; } = managedConfigurationId; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index ec0ee648..0d2f2aa1 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -5,6 +5,8 @@ namespace AIStudio.Tools.Services; public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService { public static List CURRENT_ENVIRONMENTS = []; + + public static bool HasValidEnterpriseSnapshot { get; private set; } #if DEBUG private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); @@ -33,34 +35,10 @@ public sealed class EnterpriseEnvironmentService(ILogger deleteConfigIds; - try - { - deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds(); - } - catch (Exception e) - { - logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed"); - return; - } - - foreach (var deleteId in deleteConfigIds) - { - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId); - if (isPluginInUse) - { - logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId); - PluginFactory.RemovePluginAsync(deleteId); - } - } - - // - // Step 2: Fetch all active configurations. + // Step 1: Fetch all active configurations. // List fetchedConfigs; try @@ -75,9 +53,20 @@ public sealed class EnterpriseEnvironmentService(ILogger(); + var reachableEnvironments = new List(); + var failedConfigIds = new HashSet(); + var currentEnvironmentsById = CURRENT_ENVIRONMENTS + .GroupBy(env => env.ConfigurationId) + .ToDictionary(group => group.Key, group => group.Last()); + + var activeFetchedEnvironmentsById = fetchedConfigs + .Where(config => config.IsActive) + .GroupBy(config => config.ConfigurationId) + .ToDictionary(group => group.Key, group => group.Last()); + foreach (var config in fetchedConfigs) { if (!config.IsActive) @@ -86,72 +75,98 @@ public sealed class EnterpriseEnvironmentService(ILogger 0) + var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl); + if (!etagResponse.Success) { - logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs."); - - // Remove plugins for configs that were previously active: - foreach (var oldEnv in CURRENT_ENVIRONMENTS) - { - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); - if (isPluginInUse) - PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); - } - } - else - logger.LogInformation("AI Studio runs without any enterprise configurations."); - - CURRENT_ENVIRONMENTS = []; - return; - } - - // - // Step 4: Compare with current environments and process changes. - // - var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet(); - var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet(); - - // Remove plugins for configs that are no longer present: - foreach (var oldEnv in CURRENT_ENVIRONMENTS) - { - if (!nextIds.Contains(oldEnv.ConfigurationId)) - { - logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId); - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); - if (isPluginInUse) - PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); - } - } - - // Process new or changed configs: - foreach (var nextEnv in nextEnvironments) - { - var currentEnv = CURRENT_ENVIRONMENTS.FirstOrDefault(e => e.ConfigurationId == nextEnv.ConfigurationId); - if (currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag). - { - logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId); + failedConfigIds.Add(config.ConfigurationId); + logger.LogWarning("Failed to read enterprise config metadata for '{ConfigId}' from '{ServerUrl}': {Issue}. Keeping the current plugin state for this configuration.", config.ConfigurationId, config.ConfigurationServerUrl, etagResponse.Issue ?? "Unknown issue"); continue; } - var isNew = !currentIds.Contains(nextEnv.ConfigurationId); - if(isNew) + reachableEnvironments.Add(config with { ETag = etagResponse.ETag }); + } + + // + // Step 3: Compare with current environments and process changes. + // Download per configuration. A single failure must not block others. + // + var shouldDeferStartupDownloads = isFirstRun && !PluginFactory.IsInitialized; + var effectiveEnvironmentsById = new Dictionary(); + + // Process new or changed configs: + foreach (var nextEnv in reachableEnvironments) + { + var hasCurrentEnvironment = currentEnvironmentsById.TryGetValue(nextEnv.ConfigurationId, out var currentEnv); + if (hasCurrentEnvironment && currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag). + { + logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + continue; + } + + if(!hasCurrentEnvironment) logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); else logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId); - if (isFirstRun) + if (shouldDeferStartupDownloads) + { MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + } else - await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + { + var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + if (!wasDownloadSuccessful) + { + failedConfigIds.Add(nextEnv.ConfigurationId); + if (hasCurrentEnvironment) + { + logger.LogWarning("Failed to update enterprise configuration '{ConfigId}'. Keeping the previously active version.", nextEnv.ConfigurationId); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = currentEnv; + } + else + logger.LogWarning("Failed to download the new enterprise configuration '{ConfigId}'. Skipping activation for now.", nextEnv.ConfigurationId); + + continue; + } + + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + } } - CURRENT_ENVIRONMENTS = nextEnvironments; + // Retain configurations for all failed IDs. On cold start there might be no + // previous in-memory snapshot yet, so we also keep the current fetched entry + // to protect it from cleanup while the server is unreachable. + foreach (var failedConfigId in failedConfigIds) + { + if (effectiveEnvironmentsById.ContainsKey(failedConfigId)) + continue; + + if (!currentEnvironmentsById.TryGetValue(failedConfigId, out var retainedEnvironment)) + { + if (!activeFetchedEnvironmentsById.TryGetValue(failedConfigId, out retainedEnvironment)) + continue; + + logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Protecting it from cleanup until connectivity is restored.", failedConfigId); + } + else + logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Keeping the previously active version.", failedConfigId); + + effectiveEnvironmentsById[failedConfigId] = retainedEnvironment; + } + + var effectiveEnvironments = effectiveEnvironmentsById.Values.ToList(); + + // Cleanup is only allowed after a successful sync cycle: + if (PluginFactory.IsInitialized && !shouldDeferStartupDownloads) + PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(effectiveEnvironmentsById.Keys.ToHashSet()); + + if (effectiveEnvironments.Count == 0) + logger.LogInformation("AI Studio runs without any enterprise configurations."); + + CURRENT_ENVIRONMENTS = effectiveEnvironments; + HasValidEnterpriseSnapshot = true; } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index cf8fbc26..d78567f4 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -36,13 +36,12 @@ public sealed partial class RustService var result = await this.http.GetAsync("/system/enterprise/configs"); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'"); - return []; + throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'"); } var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); if (configs is null) - return []; + throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust."); var environments = new List(); foreach (var config in configs) @@ -55,35 +54,4 @@ public sealed partial class RustService return environments; } - - /// - /// Reads all enterprise configuration IDs that should be deleted. - /// - /// - /// Returns a list of GUIDs representing configuration IDs to remove. - /// - public async Task> EnterpriseEnvDeleteConfigIds() - { - var result = await this.http.GetAsync("/system/enterprise/delete-configs"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'"); - return []; - } - - var ids = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); - if (ids is null) - return []; - - var guids = new List(); - foreach (var idStr in ids) - { - if (Guid.TryParse(idStr, out var id)) - guids.Add(id); - else - this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'."); - } - - return guids; - } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 1cab7ec4..bb4d39ba 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -4,11 +4,14 @@ - Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. - Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details. +- Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details. +- Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example, during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. - Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. +- Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. - Upgraded dependencies. \ No newline at end of file diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index dd62dd77..279214d2 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -25,8 +25,6 @@ AI Studio supports loading multiple enterprise configurations simultaneously. Th - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_ids` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS`: One or more configuration IDs that should be removed, separated by `;`. The format is: `id1;id2;id3`. This is helpful if an employee moves to a different department or leaves the organization. - - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret. **Example:** To configure two enterprise configurations (one for the organization and one for a department): @@ -37,14 +35,100 @@ MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https **Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority. +### Windows GPO / PowerShell example for `configs` + +If you distribute multiple GPOs, each GPO should read and write the same registry value (`configs`) and only update its own `id@url` entry. Other entries must stay untouched. + +The following PowerShell example provides helper functions for appending and removing entries safely: + +```powershell +$RegistryPath = "HKCU:\Software\github\MindWork AI Studio\Enterprise IT" +$ConfigsValueName = "configs" + +function Get-ConfigEntries { + param([string]$RawValue) + + if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() } + + $entries = @() + foreach ($part in $RawValue.Split(';')) { + $trimmed = $part.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } + + $pair = $trimmed.Split('@', 2) + if ($pair.Count -ne 2) { continue } + + $id = $pair[0].Trim().ToLowerInvariant() + $url = $pair[1].Trim() + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($url)) { continue } + + $entries += [PSCustomObject]@{ + Id = $id + Url = $url + } + } + + return $entries +} + +function ConvertTo-ConfigValue { + param([array]$Entries) + + return ($Entries | ForEach-Object { "$($_.Id)@$($_.Url)" }) -join ';' +} + +function Add-EnterpriseConfigEntry { + param( + [Parameter(Mandatory=$true)][Guid]$ConfigId, + [Parameter(Mandatory=$true)][string]$ServerUrl + ) + + if (-not (Test-Path $RegistryPath)) { + New-Item -Path $RegistryPath -Force | Out-Null + } + + $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName + $entries = Get-ConfigEntries -RawValue $raw + $normalizedId = $ConfigId.ToString().ToLowerInvariant() + $normalizedUrl = $ServerUrl.Trim() + + # Replace only this one ID, keep all other entries unchanged. + $entries = @($entries | Where-Object { $_.Id -ne $normalizedId }) + $entries += [PSCustomObject]@{ + Id = $normalizedId + Url = $normalizedUrl + } + + Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $entries) +} + +function Remove-EnterpriseConfigEntry { + param( + [Parameter(Mandatory=$true)][Guid]$ConfigId + ) + + if (-not (Test-Path $RegistryPath)) { return } + + $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName + $entries = Get-ConfigEntries -RawValue $raw + $normalizedId = $ConfigId.ToString().ToLowerInvariant() + + # Remove only this one ID, keep all other entries unchanged. + $updated = @($entries | Where-Object { $_.Id -ne $normalizedId }) + Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $updated) +} + +# Example usage: +# Add-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" -ServerUrl "https://intranet.example.org:30100/ai-studio/configuration" +# Remove-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" +``` + ### Single configuration (legacy) The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations: - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person. -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID`: This is a configuration ID that should be removed. This is helpful if an employee moves to a different department or leaves the organization. - - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file. - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. @@ -107,6 +191,16 @@ For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861 ID = "9072b77d-ca81-40da-be6a-861da525ef7b" ``` +## Important: Mark enterprise-managed plugins explicitly + +Configuration plugins deployed by your configuration server should define: + +```lua +DEPLOYED_USING_CONFIG_SERVER = true +``` + +Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning. + ## Example AI Studio configuration The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files: @@ -173,4 +267,4 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { } ``` -The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). \ No newline at end of file +The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 478fbff4..c5f0a6c7 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,7 +1,7 @@ use std::env; use std::sync::OnceLock; use log::{debug, info, warn}; -use rocket::{delete, get}; +use rocket::get; use rocket::serde::json::Json; use serde::Serialize; use sys_locale::get_locale; @@ -178,30 +178,6 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String { ) } -#[delete("/system/enterprise/config/id")] -pub fn delete_enterprise_env_config_id(_token: APIToken) -> String { - // - // When we are on a Windows machine, we try to read the enterprise config from - // the Windows registry. In case we can't find the registry key, or we are on a - // macOS or Linux machine, we try to read the enterprise config from the - // environment variables. - // - // The registry key is: - // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT - // - // In this registry key, we expect the following values: - // - delete_config_id - // - // The environment variable is: - // MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID - // - debug!("Trying to read the enterprise environment for some config ID, which should be deleted."); - get_enterprise_configuration( - "delete_config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", - ) -} - #[get("/system/enterprise/config/server")] pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { // @@ -314,46 +290,6 @@ pub fn read_enterprise_configs(_token: APIToken) -> Json> Json(configs) } -/// Returns all enterprise configuration IDs that should be deleted. Supports the new -/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable. -#[get("/system/enterprise/delete-configs")] -pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json> { - info!("Trying to read the enterprise environment for configuration IDs to delete."); - - let mut ids: Vec = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - - // Read the new combined format: - let combined = get_enterprise_configuration( - "delete_config_ids", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS", - ); - - if !combined.is_empty() { - for id in combined.split(';') { - let id = id.trim().to_lowercase(); - if !id.is_empty() && seen.insert(id.clone()) { - ids.push(id); - } - } - } - - // Also read the legacy single-delete variable: - let delete_id = get_enterprise_configuration( - "delete_config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", - ); - - if !delete_id.is_empty() { - let id = delete_id.trim().to_lowercase(); - if seen.insert(id.clone()) { - ids.push(id); - } - } - - Json(ids) -} - fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 3a4c1f9c..64bc8174 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -83,11 +83,9 @@ pub fn start_runtime_api() { crate::environment::get_config_directory, crate::environment::read_user_language, crate::environment::read_enterprise_env_config_id, - crate::environment::delete_enterprise_env_config_id, crate::environment::read_enterprise_env_config_server_url, crate::environment::read_enterprise_env_config_encryption_secret, crate::environment::read_enterprise_configs, - crate::environment::read_enterprise_delete_config_ids, crate::file_data::extract_data, crate::log::get_log_paths, crate::log::log_event,