From 3671444d2862f9630b998ef4411f89575f335a32 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 15 Feb 2026 18:11:57 +0100 Subject: [PATCH] Support multiple enterprise configurations (#662) --- .../Assistants/I18N/allTexts.lua | 24 ++- .../Layout/MainLayout.razor.cs | 9 +- .../Pages/Information.razor | 138 ++++++++------ .../Pages/Information.razor.cs | 12 +- .../plugin.lua | 24 ++- .../plugin.lua | 24 ++- .../PluginSystem/PluginFactory.Loading.cs | 10 + .../Tools/Rust/EnterpriseConfig.cs | 3 + .../Services/EnterpriseEnvironmentService.cs | 173 +++++++++++------- .../Tools/Services/RustService.Enterprise.cs | 131 ++++++------- .../wwwroot/changelog/v26.2.2.md | 1 + documentation/Enterprise IT.md | 38 +++- runtime/src/environment.rs | 126 ++++++++++++- runtime/src/runtime_api.rs | 2 + 14 files changed, 479 insertions(+), 236 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index becac5b0..9e66386d 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5044,12 +5044,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." @@ -5059,9 +5059,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..." + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." @@ -5125,9 +5131,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" @@ -5191,6 +5194,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog" @@ -5224,6 +5230,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!" @@ -5233,9 +5242,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index af5f3a5b..08005e68 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -211,9 +211,12 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // // Check if there is an enterprise configuration plugin to download: // - var enterpriseEnvironment = this.MessageBus.CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT).FirstOrDefault(); - if (enterpriseEnvironment != default) - await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl); + var enterpriseEnvironments = this.MessageBus + .CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) + .Where(env => env != default) + .ToList(); + foreach (var env in enterpriseEnvironments) + await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); // 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 b857f80d..5a964179 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -49,26 +49,33 @@ - @switch (EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive) + @switch (HasAnyActiveEnvironment) { - case false when this.configPlug is null: + case false when this.configPlugins.Count == 0: @T("This is a private AI Studio installation. It runs without an enterprise configuration.") break; - + case false: - @T("AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.") + @T("AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.") - -
- - @T("Configuration plugin ID:") @this.configPlug!.Id - -
-
+ @foreach (var plug in this.configPlugins) + { + +
+ + @plug.Name +
+
+ + @T("Configuration plugin ID:") @plug.Id + +
+
+ }
@@ -87,26 +94,30 @@ break; - case true when this.configPlug is null: + case true when this.configPlugins.Count == 0: - @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.") + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.") - -
- - @T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId - -
-
- - -
- - @T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl - -
-
+ @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 + +
+
+ }
@@ -127,32 +138,45 @@ case true: - @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.") + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") - -
- - @T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId - -
-
- - -
- - @T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl - -
-
- - -
- - @T("Configuration plugin ID:") @this.configPlug!.Id - -
-
+ @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 + +
+ } +
+ }
@@ -184,10 +208,10 @@ - + @T("Check for updates") - + @this.PandocButtonText @@ -195,7 +219,7 @@ - + @T("Discover MindWork AI's mission and vision on our official homepage.") @@ -236,14 +260,14 @@ @T("Startup log file") - + @T("Usage log file") - + diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index aa649a3c..a4eb5123 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -69,12 +69,14 @@ public partial class Information : MSGComponentBase private bool showDatabaseDetails; - private IPluginMetadata? configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION); + private List configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).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); + /// /// Determines whether the enterprise configuration has details that can be shown/hidden. /// Returns true if there are details available, false otherwise. @@ -83,16 +85,16 @@ public partial class Information : MSGComponentBase { get { - return EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive switch + return HasAnyActiveEnvironment switch { // Case 1: No enterprise config and no plugin - no details available - false when this.configPlug is null => false, + false when this.configPlugins.Count == 0 => false, // Case 2: Enterprise config with plugin but no central management - has details false => true, // Case 3: Enterprise config active but no plugin - has details - true when this.configPlug is null => true, + true when this.configPlugins.Count == 0 => true, // Case 4: Enterprise config active with plugin - has details true => true @@ -128,7 +130,7 @@ public partial class Information : MSGComponentBase switch (triggeredEvent) { case Event.PLUGINS_RELOADED: - this.configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION); + this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); await this.InvokeAsync(this.StateHasChanged); break; } diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index a5d01438..e8ef345b 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -5046,12 +5046,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startprotokollda -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Sehen Sie sich den Quellcode von AI Studio auf GitHub an – wir freuen uns über ihre Beiträge." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID-Konflikt: Die Plugin-ID stimmt nicht mit der ID der Unternehmenskonfiguration überein." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "Dies ist eine private AI Studio-Installation. Sie läuft ohne Unternehmenskonfiguration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist noch nicht verfügbar." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "Diese Bibliothek wird verwendet, um PDF-Dateien zu lesen. Das ist zum Beispiel notwendig, um PDFs als Datenquelle für einen Chat zu nutzen." @@ -5061,9 +5061,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Datenbankversion -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "Diese Bibliothek wird verwendet, um die MudBlazor-Bibliothek zu erweitern. Sie stellt zusätzliche Komponenten bereit, die nicht Teil der MudBlazor-Bibliothek sind." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Warten auf das Konfigurations-Plugin …" + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind aktiv." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant ist eine Vektordatenbank und Suchmaschine für Vektoren. Wir nutzen Qdrant, um lokales RAG (Retrieval-Augmented Generation) innerhalb von AI Studio zu realisieren. Vielen Dank für den Einsatz und die großartige Arbeit, die in Qdrant gesteckt wurde und weiterhin gesteckt wird." @@ -5127,9 +5133,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Konfigurationsse -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "Wir müssen Zufallszahlen erzeugen, z. B. um die Kommunikation zwischen der Benutzeroberfläche und der Laufzeitumgebung abzusichern. Die rand-Bibliothek eignet sich dafür hervorragend." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio läuft mit einer Unternehmenskonfiguration über ein Konfigurations-Plugin, ohne zentrale Konfigurationsverwaltung." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-Plugin-ID:" @@ -5193,6 +5196,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Erklärung" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind noch nicht verfügbar." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll" @@ -5226,6 +5232,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Informationen ü -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Verwendeter Rust-Compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio wird mit Unternehmenskonfigurationen unter Verwendung von Konfigurations-Plugins betrieben. Eine zentrale Konfigurationsverwaltung wird nicht eingesetzt." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri wird verwendet, um die Blazor-Benutzeroberfläche bereitzustellen. Es ist ein großartiges Projekt, das die Erstellung von Desktop-Anwendungen mit Webtechnologien ermöglicht. Ich liebe Tauri!" @@ -5235,9 +5244,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "Diese Bibliothek wird verwendet, um Excel- und OpenDocument-Tabellendateien zu lesen. Dies ist zum Beispiel notwendig, wenn Tabellen als Datenquelle für einen Chat verwendet werden sollen." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist aktiv." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version erfüllt die Anforderungen nicht" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 11e11579..562cc2a6 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -5046,12 +5046,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." @@ -5061,9 +5061,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..." + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." @@ -5127,9 +5133,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" @@ -5193,6 +5196,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog" @@ -5226,6 +5232,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!" @@ -5235,9 +5244,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 4bfbe3a3..9565a833 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -103,6 +103,16 @@ 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 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."); + } + AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath)); } catch (Exception e) diff --git a/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs b/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs new file mode 100644 index 00000000..bc6fb15e --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record EnterpriseConfig(string Id, string ServerUrl); \ 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 44645dc7..ec0ee648 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -4,7 +4,7 @@ namespace AIStudio.Tools.Services; public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService { - public static EnterpriseEnvironment CURRENT_ENVIRONMENT; + public static List CURRENT_ENVIRONMENTS = []; #if DEBUG private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); @@ -33,84 +33,125 @@ public sealed class EnterpriseEnvironmentService(ILogger plugin.Id == enterpriseRemoveConfigId); - if (enterpriseRemoveConfigId != Guid.Empty && isPlugin2RemoveInUse) - { - logger.LogWarning("The enterprise environment configuration ID '{EnterpriseRemoveConfigId}' must be removed.", enterpriseRemoveConfigId); - PluginFactory.RemovePluginAsync(enterpriseRemoveConfigId); - } - string? enterpriseConfigServerUrl; + // + // Step 1: Handle deletions first. + // + List deleteConfigIds; try { - enterpriseConfigServerUrl = await rustService.EnterpriseEnvConfigServerUrl(); + deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds(); } catch (Exception e) { - logger.LogError(e, "Failed to fetch the enterprise configuration server URL from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigServerUrl failed"); + 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; } - Guid enterpriseConfigId; - try + foreach (var deleteId in deleteConfigIds) { - enterpriseConfigId = await rustService.EnterpriseEnvConfigId(); - } - catch (Exception e) - { - logger.LogError(e, "Failed to fetch the enterprise configuration ID from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigId failed"); - return; - } - - var etag = await PluginFactory.DetermineConfigPluginETagAsync(enterpriseConfigId, enterpriseConfigServerUrl); - var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag); - if (CURRENT_ENVIRONMENT != nextEnterpriseEnvironment) - { - logger.LogInformation("The enterprise environment has changed. Updating the current environment."); - CURRENT_ENVIRONMENT = nextEnterpriseEnvironment; - - switch (enterpriseConfigServerUrl) + var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId); + if (isPluginInUse) { - case null when enterpriseConfigId == Guid.Empty: - case not null when string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty: - logger.LogInformation("AI Studio runs without an enterprise configuration."); - break; - - case null: - logger.LogWarning("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}'), but the configuration server URL is not set.", enterpriseConfigId); - break; - - case not null when !string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty: - logger.LogWarning("AI Studio runs with an enterprise configuration server URL ('{EnterpriseConfigServerUrl}'), but the configuration ID is not set.", enterpriseConfigServerUrl); - break; - - default: - logger.LogInformation("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}') and configuration server URL ('{EnterpriseConfigServerUrl}').", enterpriseConfigId, enterpriseConfigServerUrl); - - if(isFirstRun) - MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag)); - else - await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl); - break; + logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId); + PluginFactory.RemovePluginAsync(deleteId); } } - else - logger.LogInformation("The enterprise environment has not changed. No update required."); + + // + // Step 2: Fetch all active configurations. + // + List fetchedConfigs; + try + { + fetchedConfigs = await rustService.EnterpriseEnvConfigs(); + } + catch (Exception e) + { + logger.LogError(e, "Failed to fetch the enterprise configurations from the Rust service."); + await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigs failed"); + return; + } + + // + // Step 3: Determine ETags and build the next environment list. + // + var nextEnvironments = new List(); + foreach (var config in fetchedConfigs) + { + if (!config.IsActive) + { + logger.LogWarning("Skipping inactive enterprise configuration with ID '{ConfigId}'. There is either no valid server URL or config ID set.", config.ConfigurationId); + continue; + } + + var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl); + nextEnvironments.Add(config with { ETag = etag }); + } + + if (nextEnvironments.Count == 0) + { + if (CURRENT_ENVIRONMENTS.Count > 0) + { + 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); + continue; + } + + var isNew = !currentIds.Contains(nextEnv.ConfigurationId); + if(isNew) + 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) + MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv); + else + await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + } + + CURRENT_ENVIRONMENTS = nextEnvironments; } 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 004d445a..cf8fbc26 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -1,71 +1,9 @@ -namespace AIStudio.Tools.Services; +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; public sealed partial class RustService { - /// - /// Tries to read the enterprise environment for the current user's configuration ID. - /// - /// - /// Returns the empty Guid when the environment is not set or the request fails. - /// Otherwise, the configuration ID. - /// - public async Task EnterpriseEnvConfigId() - { - var result = await this.http.GetAsync("/system/enterprise/config/id"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration ID: '{result.StatusCode}'"); - return Guid.Empty; - } - - Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId); - return configurationId; - } - - /// - /// Tries to read the enterprise environment for a configuration ID, which must be removed. - /// - /// - /// Removing a configuration ID is necessary when the user moved to another department or - /// left the company, or when the configuration ID is no longer valid. - /// - /// - /// Returns the empty Guid when the environment is not set or the request fails. - /// Otherwise, the configuration ID. - /// - public async Task EnterpriseEnvRemoveConfigId() - { - var result = await this.http.DeleteAsync("/system/enterprise/config/id"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration ID for removal: '{result.StatusCode}'"); - return Guid.Empty; - } - - Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId); - return configurationId; - } - - /// - /// Tries to read the enterprise environment for the current user's configuration server URL. - /// - /// - /// Returns null when the environment is not set or the request fails. - /// Otherwise, the configuration server URL. - /// - public async Task EnterpriseEnvConfigServerUrl() - { - var result = await this.http.GetAsync("/system/enterprise/config/server"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration server URL: '{result.StatusCode}'"); - return string.Empty; - } - - var serverUrl = await result.Content.ReadAsStringAsync(); - return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl; - } - /// /// Tries to read the enterprise environment for the configuration encryption secret. /// @@ -85,4 +23,67 @@ public sealed partial class RustService var encryptionSecret = await result.Content.ReadAsStringAsync(); return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret; } + + /// + /// Reads all enterprise configurations (multi-config support). + /// + /// + /// Returns a list of enterprise environments parsed from the Rust runtime. + /// The ETag is not yet determined; callers must resolve it separately. + /// + public async Task> EnterpriseEnvConfigs() + { + var result = await this.http.GetAsync("/system/enterprise/configs"); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'"); + return []; + } + + var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); + if (configs is null) + return []; + + var environments = new List(); + foreach (var config in configs) + { + if (Guid.TryParse(config.Id, out var id)) + environments.Add(new EnterpriseEnvironment(config.ServerUrl, id, null)); + else + this.logger!.LogWarning($"Skipping enterprise config with invalid ID: '{config.Id}'."); + } + + 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 abbe60f4..af8eff4b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -3,6 +3,7 @@ - Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings. - 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. - 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 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. diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 39d4fbd2..dd62dd77 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -13,13 +13,33 @@ Do you want to manage MindWork AI Studio in a corporate environment or within an AI Studio checks about every 16 minutes to see if the configuration ID, the server for the configuration, or the configuration itself has changed. If it finds any changes, it loads the updated configuration from the server and applies it right away. ## Configure the devices -So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees’ devices. Currently, the following options are available: +So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees' devices. Currently, the following options are available: - **Registry** (only available for Microsoft Windows): On Windows devices, AI Studio first tries to read the information from the registry. The registry information can be managed and distributed centrally as a so-called Group Policy Object (GPO). - **Environment variables**: On all operating systems (on Windows as a fallback after the registry), AI Studio tries to read the configuration metadata from environment variables. -The following keys and values (registry) and variables are checked and read: +### Multiple configurations (recommended) + +AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, e.g., organization-wide settings combined with department-specific settings. The following keys and variables are used: + +- 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): + +``` +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https://intranet.my-company.com:30100/ai-studio/configuration;a1b2c3d4-e5f6-7890-abcd-ef1234567890@https://intranet.my-company.com:30100/ai-studio/department-config +``` + +**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. + +### 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. @@ -29,11 +49,13 @@ The following keys and values (registry) and variables are checked and read: - 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. +### How configurations are downloaded + Let's assume as example that `https://intranet.my-company.com:30100/ai-studio/configuration` is the server address and `9072b77d-ca81-40da-be6a-861da525ef7b` is the configuration ID. AI Studio will derive the following address from this information: `https://intranet.my-company.com:30100/ai-studio/configuration/9072b77d-ca81-40da-be6a-861da525ef7b.zip`. Important: The configuration ID will always be written in lowercase, even if it is configured in uppercase. If `9072B77D-CA81-40DA-BE6A-861DA525EF7B` is configured, the same address will be derived. Your web server must be configured accordingly. Finally, AI Studio will send a GET request and download the ZIP file. The ZIP file only contains the files necessary for the configuration. It's normal to include a file for an icon along with the actual configuration plugin. -Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this. +Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this. When using multiple configurations, each configuration is checked independently. ## Configure the configuration web server @@ -75,6 +97,16 @@ intranet.my-company.com:30100 { } ``` +## Important: Plugin ID must match the enterprise configuration ID + +The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID used in the registry or environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page. + +For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861da525ef7b`, then your plugin must declare: + +```lua +ID = "9072b77d-ca81-40da-be6a-861da525ef7b" +``` + ## 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: diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 6203cac0..f3ccdc60 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,7 +1,9 @@ use std::env; use std::sync::OnceLock; -use log::{debug, warn}; +use log::{debug, info, warn}; use rocket::{delete, get}; +use rocket::serde::json::Json; +use serde::Serialize; use sys_locale::get_locale; use crate::api_token::APIToken; @@ -143,23 +145,127 @@ pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String ) } +/// Represents a single enterprise configuration entry with an ID and server URL. +#[derive(Serialize)] +pub struct EnterpriseConfig { + pub id: String, + pub server_url: String, +} + +/// Returns all enterprise configurations. Collects configurations from both the +/// new multi-config format (`id1@url1;id2@url2`) and the legacy single-config +/// environment variables, merging them into one list. Duplicates (by ID) are +/// skipped — the first occurrence wins. +#[get("/system/enterprise/configs")] +pub fn read_enterprise_configs(_token: APIToken) -> Json> { + info!("Trying to read the enterprise environment for all configurations."); + + let mut configs: Vec = Vec::new(); + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + + // Read the new combined format: + let combined = get_enterprise_configuration( + "configs", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS", + ); + + if !combined.is_empty() { + // Parse the new format: id1@url1;id2@url2;... + for entry in combined.split(';') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + + // Split at the first '@' (GUIDs never contain '@'): + if let Some((id, url)) = entry.split_once('@') { + let id = id.trim().to_lowercase(); + let url = url.trim().to_string(); + if !id.is_empty() && !url.is_empty() && seen_ids.insert(id.clone()) { + configs.push(EnterpriseConfig { id, server_url: url }); + } + } + } + } + + // Also read the legacy single-config variables: + let config_id = get_enterprise_configuration( + "config_id", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", + ); + + let config_server_url = get_enterprise_configuration( + "config_server_url", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", + ); + + if !config_id.is_empty() && !config_server_url.is_empty() { + let id = config_id.trim().to_lowercase(); + if seen_ids.insert(id.clone()) { + configs.push(EnterpriseConfig { id, server_url: config_server_url }); + } + } + + 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")] { - debug!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT' or environment variables."); + info!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}' or the environment variable '{}'.", _reg_value, env_name); use windows_registry::*; let key_path = r"Software\github\MindWork AI Studio\Enterprise IT"; let key = match CURRENT_USER.open(key_path) { Ok(key) => key, Err(_) => { - debug!(r"Could not read the registry key HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT. Falling back to environment variables."); + info!(r"Could not read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}'. Falling back to the environment variable '{}'.", _reg_value, env_name); return match env::var(env_name) { Ok(val) => { - debug!("Falling back to the environment variable '{}' was successful.", env_name); + info!("Falling back to the environment variable '{}' was successful.", env_name); val }, Err(_) => { - debug!("Falling back to the environment variable '{}' was not successful.", env_name); + info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); "".to_string() }, } @@ -169,14 +275,14 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { match key.get_string(_reg_value) { Ok(val) => val, Err(_) => { - debug!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to environment variables.", _reg_value); + info!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to the environment variable '{}'.", _reg_value, env_name); match env::var(env_name) { Ok(val) => { - debug!("Falling back to the environment variable '{}' was successful.", env_name); + info!("Falling back to the environment variable '{}' was successful.", env_name); val }, Err(_) => { - debug!("Falling back to the environment variable '{}' was not successful.", env_name); + info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); "".to_string() } } @@ -184,11 +290,11 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { } } else { // In the case of macOS or Linux, we just read the environment variable: - debug!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name); + info!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name); match env::var(env_name) { Ok(val) => val, Err(_) => { - debug!("The environment variable '{}' was not found.", env_name); + info!("The environment variable '{}' was not found. It seems that there is no enterprise environment available.", env_name); "".to_string() } } diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 647259f3..3a4c1f9c 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -86,6 +86,8 @@ pub fn start_runtime_api() { 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,