From 4cf62672de7dece601b6d90b235b30ead823c7df Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 7 Feb 2026 22:59:41 +0100 Subject: [PATCH 01/11] Added settings & features for administrators in organizations (#653) --- .../DocumentAnalysisAssistant.razor | 15 +- .../Assistants/I18N/allTexts.lua | 48 ++++ .../Settings/SettingsPanelApp.razor | 24 +- .../Settings/SettingsPanelApp.razor.cs | 6 + .../Components/Settings/SettingsPanelBase.cs | 3 + .../Settings/SettingsPanelEmbeddings.razor | 9 +- .../Settings/SettingsPanelEmbeddings.razor.cs | 12 +- .../Settings/SettingsPanelProviderBase.cs | 61 +++++ .../Settings/SettingsPanelProviders.razor | 9 +- .../Settings/SettingsPanelProviders.razor.cs | 12 +- .../Settings/SettingsPanelTranscription.razor | 7 +- .../SettingsPanelTranscription.razor.cs | 12 +- .../Layout/MainLayout.razor.cs | 3 + .../Pages/Information.razor | 50 ++++- .../Plugins/configuration/plugin.lua | 28 ++- .../plugin.lua | 50 ++++- .../plugin.lua | 50 ++++- .../Provider/BaseProvider.cs | 7 +- .../Provider/LLMProvidersExtensions.cs | 44 ++-- .../Settings/DataModel/DataApp.cs | 7 +- .../Settings/EmbeddingProvider.cs | 64 +++++- app/MindWork AI Studio/Settings/Provider.cs | 87 +++++++- .../Settings/TranscriptionProvider.cs | 64 +++++- .../Tools/EnterpriseEncryption.cs | 211 ++++++++++++++++++ app/MindWork AI Studio/Tools/ISecretId.cs | 7 + app/MindWork AI Studio/Tools/LuaTools.cs | 16 ++ .../PluginSystem/PendingEnterpriseApiKey.cs | 49 ++++ .../Tools/PluginSystem/PluginConfiguration.cs | 48 +++- .../PluginSystem/PluginConfigurationObject.cs | 32 ++- .../PluginSystem/PluginFactory.Loading.cs | 20 +- .../Tools/PluginSystem/PluginFactory.cs | 23 ++ .../Tools/Services/RustService.Enterprise.cs | 20 ++ app/MindWork AI Studio/packages.lock.json | 37 +++ .../wwwroot/changelog/v26.2.2.md | 6 +- documentation/Enterprise IT.md | 63 +++++- runtime/Cargo.lock | 191 +++++++++++++++- runtime/src/environment.rs | 24 ++ runtime/src/runtime_api.rs | 1 + 38 files changed, 1330 insertions(+), 90 deletions(-) create mode 100644 app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs create mode 100644 app/MindWork AI Studio/Tools/EnterpriseEncryption.cs create mode 100644 app/MindWork AI Studio/Tools/LuaTools.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index aa77f9fb..51dd8f7d 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -136,13 +136,16 @@ else - - @T("Preparation for enterprise distribution") - + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + @T("Preparation for enterprise distribution") + - - @T("Export policy as configuration section") - + + @T("Export policy as configuration section") + + } } diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index f825d229..e1188a53 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2080,12 +2080,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -2095,6 +2101,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Administration settings are visible" + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" @@ -2104,9 +2113,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Show administration settings?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Read the Enterprise IT documentation for details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Enable spellchecking?" @@ -2140,6 +2158,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Generate an encryption secret and copy it to the clipboard" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" @@ -2197,6 +2221,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Export configuration" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Cannot export the encrypted API key: No enterprise encryption secret is configured." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "Export API Key?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Show provider's confidence level?" @@ -2302,6 +2338,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Export configuration" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "No transcription provider configured yet." @@ -2356,6 +2395,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Export configuration" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard" @@ -5017,6 +5059,9 @@ 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." +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" + -- 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." @@ -5053,6 +5098,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "In order to use -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 62b996d0..a07fc65f 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -17,6 +17,7 @@ + @if (this.SettingsManager.ConfigurationData.App.PreviewVisibility > PreviewVisibility.NONE) @@ -36,4 +37,25 @@ } - \ No newline at end of file + + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + @T("Enterprise Administration") + + + + @T("Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings.") + + @T("Read the Enterprise IT documentation for details.") + + + + + @T("Generate an encryption secret and copy it to the clipboard") + + } + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs index 2fbb61ed..81c2b7e5 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs @@ -6,6 +6,12 @@ namespace AIStudio.Components.Settings; public partial class SettingsPanelApp : SettingsPanelBase { + private async Task GenerateEncryptionSecret() + { + var secret = EnterpriseEncryption.GenerateSecret(); + await this.RustService.CopyText2Clipboard(this.Snackbar, secret); + } + private IEnumerable> GetFilteredTranscriptionProviders() { yield return new(T("Disable dictation and transcription"), string.Empty); diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs index bad3fca3..871d8353 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs @@ -15,4 +15,7 @@ public abstract class SettingsPanelBase : MSGComponentBase [Inject] protected RustService RustService { get; init; } = null!; + + [Inject] + protected ISnackbar Snackbar { get; init; } = null!; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index 874bc3c9..addc4088 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings.DataModel -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager)) { @@ -22,7 +22,7 @@ - + # @@ -53,6 +53,9 @@ + + + @@ -73,4 +76,4 @@ @T("Add Embedding") -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 94878987..02b46c1a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -7,7 +7,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelEmbeddings : SettingsPanelBase +public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase { [Parameter] public List> AvailableEmbeddingProviders { get; set; } = new(); @@ -114,6 +114,14 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase await this.UpdateEmbeddingProviders(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportEmbeddingProvider(EmbeddingProvider provider) + { + if (provider == EmbeddingProvider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.EMBEDDING_PROVIDER, provider.ExportAsConfigurationSection); + } private async Task UpdateEmbeddingProviders() { @@ -123,4 +131,4 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase await this.AvailableEmbeddingProvidersChanged.InvokeAsync(this.AvailableEmbeddingProviders); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs new file mode 100644 index 00000000..9503365c --- /dev/null +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs @@ -0,0 +1,61 @@ +using AIStudio.Dialogs; +using AIStudio.Tools.PluginSystem; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Components.Settings; + +public abstract class SettingsPanelProviderBase : SettingsPanelBase +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SettingsPanelProviderBase).Namespace, nameof(SettingsPanelProviderBase)); + + /// + /// Exports the provider configuration as Lua code, optionally including the encrypted API key if the provider has one + /// configured and the user agrees to include it. The exportFunc should generate the Lua code based on the provided + /// encrypted API key (which may be null if the user chose not to include it or if encryption is not available). + /// The generated Lua code is then copied to the clipboard for easy sharing. + /// + /// The secret ID of the provider to check for an API key. + /// The type of secret store to check for the API key (e.g., LLM provider, transcription provider, etc.). + /// The function that generates the Lua code for the provider configuration, given the optional encrypted API key. + protected async Task ExportProvider(ISecretId secretId, SecretStoreType storeType, Func exportFunc) + { + string? encryptedApiKey = null; + + // Check if the provider has an API key stored: + var apiKeyResponse = await this.RustService.GetAPIKey(secretId, storeType, isTrying: true); + if (apiKeyResponse.Success) + { + // Ask the user if they want to export the API key: + var dialogParameters = new DialogParameters + { + { x => x.Message, TB("This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(TB("Export API Key?"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is { Canceled: false }) + { + // User wants to export the API key - encrypt it: + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + var decryptedApiKey = await apiKeyResponse.Secret.Decrypt(Program.ENCRYPTION); + if (encryption.TryEncrypt(decryptedApiKey, out var encrypted)) + encryptedApiKey = encrypted; + } + else + { + // No encryption secret available - inform the user: + this.Snackbar.Add(TB("Cannot export the encrypted API key: No enterprise encryption secret is configured."), Severity.Warning); + } + } + } + + var luaCode = exportFunc(encryptedApiKey); + if (string.IsNullOrWhiteSpace(luaCode)) + return; + + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index 3d359408..21cc511d 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @@ -15,7 +15,7 @@ - + # @@ -45,6 +45,9 @@ + + + @@ -117,4 +120,4 @@ } } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 2272959d..3388372a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -10,7 +10,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelProviders : SettingsPanelBase +public partial class SettingsPanelProviders : SettingsPanelProviderBase { [Parameter] public List> AvailableLLMProviders { get; set; } = new(); @@ -134,6 +134,14 @@ public partial class SettingsPanelProviders : SettingsPanelBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private async Task ExportLLMProvider(AIStudio.Settings.Provider provider) + { + if (provider == AIStudio.Settings.Provider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.LLM_PROVIDER, provider.ExportAsConfigurationSection); + } + private string GetLLMProviderModelName(AIStudio.Settings.Provider provider) { // For system models, return localized text: @@ -176,4 +184,4 @@ public partial class SettingsPanelProviders : SettingsPanelBase this.SettingsManager.ConfigurationData.LLMProviders.CustomConfidenceScheme[llmProvider] = level; await this.SettingsManager.StoreSettings(); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index aff415b2..43da4dc6 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings.DataModel -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { @@ -19,7 +19,7 @@ - + # @@ -50,6 +50,9 @@ + + + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs index 243200a3..fadd002a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs @@ -7,7 +7,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelTranscription : SettingsPanelBase +public partial class SettingsPanelTranscription : SettingsPanelProviderBase { [Parameter] public List> AvailableTranscriptionProviders { get; set; } = new(); @@ -114,6 +114,14 @@ public partial class SettingsPanelTranscription : SettingsPanelBase await this.UpdateTranscriptionProviders(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportTranscriptionProvider(TranscriptionProvider provider) + { + if (provider == TranscriptionProvider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.TRANSCRIPTION_PROVIDER, provider.ExportAsConfigurationSection); + } private async Task UpdateTranscriptionProviders() { @@ -123,4 +131,4 @@ public partial class SettingsPanelTranscription : SettingsPanelBase await this.AvailableTranscriptionProvidersChanged.InvokeAsync(this.AvailableTranscriptionProviders); } -} \ 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 4516b81b..af5f3a5b 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -215,6 +215,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan if (enterpriseEnvironment != default) await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl); + // Initialize the enterprise encryption service for decrypting API keys: + await PluginFactory.InitializeEnterpriseEncryption(this.RustService); + // Load (but not start) all plugins without waiting for them: #if DEBUG var pluginLoadingTimeout = new CancellationTokenSource(); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 72673982..32238d06 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -1,4 +1,5 @@ @attribute [Route(Routes.ABOUT)] +@using AIStudio.Tools.PluginSystem @using AIStudio.Tools.Services @inherits MSGComponentBase @@ -68,9 +69,24 @@ + +
+ + @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) + { + + @T("Encryption secret: is configured") + } + else + { + + @T("Encryption secret: is not configured") + } +
+
break; - + case true when this.configPlug is null: @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.") @@ -91,9 +107,24 @@ + +
+ + @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 a configuration server. The configuration plugin is active.") @@ -122,6 +153,21 @@ + +
+ + @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/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 73061b73..3e12a567 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -64,6 +64,20 @@ CONFIG["LLM_PROVIDERS"] = {} -- -- Could be something like ... \"temperature\": 0.5, \"max_tokens\": 1000 ... for multiple parameters. -- -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. -- ["AdditionalJsonApiParameters"] = "", +-- +-- -- Optional: Hugging Face inference provider. Only relevant for UsedLLMProvider = HUGGINGFACE. +-- -- Allowed values are: CEREBRAS, NEBIUS_AI_STUDIO, SAMBANOVA, NOVITA, HYPERBOLIC, TOGETHER_AI, FIREWORKS, HF_INFERENCE_API +-- -- ["HFInferenceProvider"] = "NOVITA", +-- +-- -- Optional: Encrypted API key for cloud providers or secured on-premise models. +-- -- The API key must be encrypted using the enterprise encryption secret. +-- -- Format: "ENC:v1:" +-- -- The encryption secret must be configured via: +-- -- Windows Registry: HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret +-- -- Environment variable: MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET +-- -- You can export an encrypted API key from an existing provider using the export button in the settings. +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -82,6 +96,10 @@ CONFIG["TRANSCRIPTION_PROVIDERS"] = {} -- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, VLLM, and WHISPER_CPP -- ["Host"] = "WHISPER_CPP", -- ["Hostname"] = "", +-- +-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details) +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -100,6 +118,10 @@ CONFIG["EMBEDDING_PROVIDERS"] = {} -- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, and VLLM -- ["Host"] = "OLLAMA", -- ["Hostname"] = "", +-- +-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details) +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -120,6 +142,10 @@ CONFIG["SETTINGS"] = {} -- Allowed values are: true, false -- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false +-- Configure whether administration settings are visible in the UI: +-- Allowed values are: true, false +-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true + -- Configure the visibility of preview features: -- Allowed values are: NONE, RELEASE_CANDIDATE, BETA, ALPHA, PROTOTYPE, EXPERIMENTAL -- Please note: @@ -260,4 +286,4 @@ CONFIG["PROFILES"] = {} -- ["Name"] = "", -- ["NeedToKnow"] = "I like to cook in my free time. My favorite meal is ...", -- ["Actions"] = "Please always ensure the portion size is ..." --- } \ No newline at end of file +-- } 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 5f4adf5d..9612b2ef 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 @@ -2082,12 +2082,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Wählen Sie die Sprache für die App aus." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "Wenn diese Option aktiviert ist, werden zusätzliche Optionen für die Administration angezeigt. Diese Optionen sind für IT-Mitarbeitende vorgesehen, um organisationsweite Einstellungen zu verwalten, z. B. Anbieter für eine gesamte Organisation zu konfigurieren und zu exportieren." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "Der globale Tastaturkurzbefehl zum Ein- und Ausschalten der Sprachaufnahme. Dieser Kurzbefehl funktioniert systemweit, auch wenn die App nicht im Vordergrund ist." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Diktieren und Transkribieren deaktivieren" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Unternehmensverwaltung" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Sprachverhalten" @@ -2097,6 +2103,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Sprache" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Die Optionen für die Administration sind sichtbar." + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Energie sparen?" @@ -2106,9 +2115,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App-Einstellungen" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generieren Sie ein 256‑Bit‑Geheimnis für die Verschlüsselung, um API‑Schlüssel in Konfigurations-Plugins zu verschlüsseln. Stellen Sie dieses Geheimnis über Gruppenrichtlinien (Windows-Registrierung) oder über Umgebungsvariablen auf Client-Geräten bereit. Anschließend können Anbieter über die Export-Schaltflächen in den Anbieter-Einstellungen mit verschlüsselten API‑Schlüsseln exportiert werden." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "Wenn aktiviert, wird gestreamter Inhalt von der KI alle drei Sekunden aktualisiert. Wenn deaktiviert, wird gestreamter Inhalt sofort aktualisiert, sobald er verfügbar ist." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Optionen für die Administration anzeigen?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Lesen Sie die Enterprise-IT-Dokumentation für die Details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Rechtschreibprüfung aktivieren?" @@ -2142,6 +2160,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Möchten Sie einen Anbieter als Standard für die gesamte App festlegen? Wenn Sie einen anderen Anbieter für einen Assistenten konfigurieren, hat dieser immer Vorrang." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Geheimnis für die Verschlüsselung generieren und in die Zwischenablage kopieren" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Die Optionen für die Administration sind nicht sichtbar." + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Löschen" @@ -2199,6 +2223,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Konfiguration exportieren" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Der verschlüsselte API-Schlüssel kann nicht exportiert werden: Es ist kein Geheimnis für die Verschlüsselung konfiguriert." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "Für diesen Anbieter ist ein API-Schlüssel konfiguriert. Möchten Sie den verschlüsselten API-Schlüssel in den Export aufnehmen? Hinweis: Der Empfänger benötigt dasselbe Geheimnis für die Verschlüsselung, um den API-Schlüssel verwenden zu können." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "API-Schlüssel exportieren?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Anzeigen, wie sicher sich der Anbieter ist?" @@ -2304,6 +2340,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Konfiguration exportieren" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "Es ist bisher kein Anbieter für Transkriptionen konfiguriert." @@ -2358,6 +2397,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Konfiguration exportieren" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Kopiere {0} in die Zwischenablage" @@ -5019,7 +5061,10 @@ 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." --- 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. +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert" + +-- 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." -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. @@ -5055,6 +5100,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "Um ein beliebige -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek wird verwendet, um HTML in Markdown umzuwandeln. Das ist zum Beispiel notwendig, wenn Sie eine URL als Eingabe für einen Assistenten angeben." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Geheimnis für die Verschlüsselung: ist konfiguriert" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "Wir verwenden Rocket zur Implementierung der Runtime-API. Dies ist notwendig, da die Runtime mit der Benutzeroberfläche (IPC) kommunizieren muss. Rocket ist ein ausgezeichnetes Framework zur Umsetzung von Web-APIs in Rust." 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 5a4f9f78..4be2328f 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 @@ -2082,12 +2082,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -2097,6 +2103,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Administration settings are visible" + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" @@ -2106,9 +2115,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Show administration settings?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Read the Enterprise IT documentation for details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Enable spellchecking?" @@ -2142,6 +2160,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Generate an encryption secret and copy it to the clipboard" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" @@ -2199,6 +2223,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Export configuration" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Cannot export the encrypted API key: No enterprise encryption secret is configured." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "Export API Key?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Show provider's confidence level?" @@ -2304,6 +2340,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Export configuration" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "No transcription provider configured yet." @@ -2358,6 +2397,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Export configuration" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard" @@ -5019,7 +5061,10 @@ 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." --- 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. +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" + +-- 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." -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. @@ -5055,6 +5100,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "In order to use -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 8400e9a3..0cf8a362 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -112,9 +112,14 @@ public abstract class BaseProvider : IProvider, ISecretId #endregion + /// + /// Whether this provider was imported from an enterprise configuration plugin. + /// + public bool IsEnterpriseConfiguration { get; init; } + #region Implementation of ISecretId - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; public string SecretName => this.InstanceName; diff --git a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs index ffaa0d06..e71cef95 100644 --- a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs +++ b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs @@ -186,7 +186,7 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this AIStudio.Settings.Provider providerSettings) { - return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.Model, providerSettings.HFInferenceProvider, providerSettings.AdditionalJsonApiParameters); + return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.Model, providerSettings.HFInferenceProvider, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration); } /// @@ -196,7 +196,7 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this EmbeddingProvider embeddingProviderSettings) { - return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.Model, HFInferenceProvider.NONE); + return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.Model, HFInferenceProvider.NONE, isEnterpriseConfiguration: embeddingProviderSettings.IsEnterpriseConfiguration); } /// @@ -206,34 +206,34 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this TranscriptionProvider transcriptionProviderSettings) { - return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.Model, HFInferenceProvider.NONE); + return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.Model, HFInferenceProvider.NONE, isEnterpriseConfiguration: transcriptionProviderSettings.IsEnterpriseConfiguration); } - private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, Model model, HFInferenceProvider inferenceProvider, string expertProviderApiParameter = "") + private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, Model model, HFInferenceProvider inferenceProvider, string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false) { try { return provider switch { - LLMProviders.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.MISTRAL => new ProviderMistral { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.GOOGLE => new ProviderGoogle { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.X => new ProviderX { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.DEEP_SEEK => new ProviderDeepSeek { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.ALIBABA_CLOUD => new ProviderAlibabaCloud { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, + LLMProviders.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.MISTRAL => new ProviderMistral { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.GOOGLE => new ProviderGoogle { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.X => new ProviderX { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.DEEP_SEEK => new ProviderDeepSeek { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.ALIBABA_CLOUD => new ProviderAlibabaCloud { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, model) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.SELF_HOSTED => new ProviderSelfHosted(host, hostname) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.HELMHOLTZ => new ProviderHelmholtz { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.GWDG => new ProviderGWDG { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, - LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, model) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - - LLMProviders.SELF_HOSTED => new ProviderSelfHosted(host, hostname) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - - LLMProviders.HELMHOLTZ => new ProviderHelmholtz { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.GWDG => new ProviderGWDG { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - _ => new NoProvider(), }; } diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index fe4409de..5671908f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -93,9 +93,14 @@ public sealed class DataApp(Expression>? configSelection = n /// Should the user be allowed to add providers? /// public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true); + + /// + /// Should administration settings be visible in the UI? + /// + public bool ShowAdminSettings { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowAdminSettings, false); /// /// List of assistants that should be hidden from the UI. /// public HashSet HiddenAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HiddenAssistants, []); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index e88831f0..59909b25 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -43,7 +43,7 @@ public sealed record EmbeddingProvider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -110,6 +110,34 @@ public sealed record EmbeddingProvider( Host = host, }; + // Handle encrypted API key if present: + 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."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + name, + decryptedApiKey, + SecretStoreType.EMBEDDING_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -131,4 +159,36 @@ public sealed record EmbeddingProvider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the embedding provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["EMBEDDING_PROVIDERS"][#CONFIG["EMBEDDING_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{apiKeyLine}} + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 89a0dbbd..2990655a 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -71,7 +71,7 @@ public sealed record Provider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -121,6 +121,16 @@ public sealed record Provider( LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname."); return false; } + + var hfInferenceProvider = HFInferenceProvider.NONE; + if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead(out var hfInferenceProviderText)) + { + if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider)) + { + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value."); + hfInferenceProvider = HFInferenceProvider.NONE; + } + } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { @@ -153,9 +163,38 @@ public sealed record Provider( EnterpriseConfigurationPluginId = configPluginId, Hostname = hostname, Host = host, + HFInferenceProvider = hfInferenceProvider, AdditionalJsonApiParameters = additionalJsonApiParameters, }; - + + // Handle encrypted API key if present: + 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."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + instanceName, + decryptedApiKey, + SecretStoreType.LLM_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -177,4 +216,46 @@ public sealed record Provider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var hfInferenceProviderLine = string.Empty; + if (this.HFInferenceProvider is not HFInferenceProvider.NONE) + { + hfInferenceProviderLine = $""" + ["HFInferenceProvider"] = "{this.HFInferenceProvider}", + """; + } + + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["InstanceName"] = "{{LuaTools.EscapeLuaString(this.InstanceName)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{hfInferenceProviderLine}} + {{apiKeyLine}} + ["AdditionalJsonApiParameters"] = "{{LuaTools.EscapeLuaString(this.AdditionalJsonApiParameters)}}", + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index 7a5f2ef5..c4acf865 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -43,7 +43,7 @@ public sealed record TranscriptionProvider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -110,6 +110,34 @@ public sealed record TranscriptionProvider( Host = host, }; + // Handle encrypted API key if present: + 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."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + name, + decryptedApiKey, + SecretStoreType.TRANSCRIPTION_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -131,4 +159,36 @@ public sealed record TranscriptionProvider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the transcription provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["TRANSCRIPTION_PROVIDERS"][#CONFIG["TRANSCRIPTION_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{apiKeyLine}} + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs b/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs new file mode 100644 index 00000000..d32aeb1b --- /dev/null +++ b/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs @@ -0,0 +1,211 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AIStudio.Tools; + +/// +/// Provides encryption and decryption functionality for enterprise configuration plugins. +/// This is used to encrypt/decrypt API keys in Lua configuration files. +/// +/// +/// Important: This is obfuscation, not security. Users with administrative access +/// to their machines can potentially extract the decrypted API keys. This feature +/// is designed to prevent casual exposure of API keys in configuration files. It +/// also protects against accidental leaks while sharing configuration snippets, +/// as the encrypted values cannot be decrypted without the secret key. +/// +public sealed class EnterpriseEncryption +{ + /// + /// The number of iterations to derive the key and IV from the password. + /// We use a higher iteration count here because the secret is static + /// (not regenerated each startup like the IPC encryption). + /// + private const int ITERATIONS = 10_000; + + /// + /// The length of the salt in bytes. + /// + private const int SALT_LENGTH = 16; + + /// + /// The prefix for encrypted values. + /// + private const string PREFIX = "ENC:v1:"; + + private readonly ILogger logger; + private readonly byte[]? secretKey; + + /// + /// Gets a value indicating whether the encryption service is available. + /// + public bool IsAvailable { get; } + + /// + /// Creates a new instance of the enterprise encryption service. + /// + /// The logger instance. + /// The base64-encoded 32-byte encryption secret. + public EnterpriseEncryption(ILogger logger, string? base64Secret) + { + this.logger = logger; + + if (string.IsNullOrWhiteSpace(base64Secret)) + { + this.logger.LogWarning("No enterprise encryption secret configured. Encrypted API keys in configuration plugins will not be available."); + this.IsAvailable = false; + return; + } + + try + { + this.secretKey = Convert.FromBase64String(base64Secret); + if (this.secretKey.Length != 32) + { + this.logger.LogWarning($"The enterprise encryption secret must be exactly 32 bytes (256 bits). Got {this.secretKey.Length} bytes."); + this.secretKey = null; + this.IsAvailable = false; + return; + } + + this.IsAvailable = true; + this.logger.LogInformation("Enterprise encryption service initialized successfully."); + } + catch (FormatException ex) + { + this.logger.LogWarning(ex, "Failed to decode the enterprise encryption secret from base64."); + this.IsAvailable = false; + } + } + + /// + /// Checks if the given value is encrypted (has the encryption prefix). + /// + /// The value to check. + /// True if the value starts with the encryption prefix; otherwise, false. + public static bool IsEncrypted(string? value) => value?.StartsWith(PREFIX, StringComparison.Ordinal) ?? false; + + /// + /// Tries to decrypt an encrypted value. + /// + /// The encrypted value (with ENC:v1: prefix). + /// When successful, contains the decrypted plaintext. + /// True if decryption was successful; otherwise, false. + public bool TryDecrypt(string encryptedValue, out string decryptedValue) + { + decryptedValue = string.Empty; + if (!this.IsAvailable) + { + this.logger.LogWarning("Cannot decrypt: Enterprise encryption service is not available."); + return false; + } + + if (!IsEncrypted(encryptedValue)) + { + this.logger.LogWarning("Cannot decrypt: Value does not have the expected encryption prefix."); + return false; + } + + try + { + // Extract the base64-encoded data after the prefix: + var base64Data = encryptedValue[PREFIX.Length..]; + var encryptedBytes = Convert.FromBase64String(base64Data); + if (encryptedBytes.Length < SALT_LENGTH + 1) + { + this.logger.LogWarning("Cannot decrypt: Encrypted data is too short."); + return false; + } + + // Extract salt and encrypted content: + var salt = encryptedBytes[..SALT_LENGTH]; + var cipherText = encryptedBytes[SALT_LENGTH..]; + + // Derive key and IV using PBKDF2: + using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512); + var key = keyDerivation.GetBytes(32); // AES-256 + var iv = keyDerivation.GetBytes(16); // AES block size + + // Decrypt using AES-256-CBC: + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var decryptedBytes = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length); + decryptedValue = Encoding.UTF8.GetString(decryptedBytes); + + return true; + } + catch (FormatException ex) + { + this.logger.LogWarning(ex, "Failed to decode encrypted value from base64."); + return false; + } + catch (CryptographicException ex) + { + this.logger.LogWarning(ex, "Failed to decrypt value. The encryption secret may be incorrect."); + return false; + } + } + + /// + /// Encrypts a plaintext value. + /// + /// The plaintext to encrypt. + /// When successful, contains the encrypted value with prefix. + /// True if encryption was successful; otherwise, false. + public bool TryEncrypt(string plaintext, out string encryptedValue) + { + encryptedValue = string.Empty; + if (!this.IsAvailable) + { + this.logger.LogWarning("Cannot encrypt: Enterprise encryption service is not available."); + return false; + } + + try + { + // Generate a random salt: + var salt = RandomNumberGenerator.GetBytes(SALT_LENGTH); + + // Derive key and IV using PBKDF2: + using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512); + var key = keyDerivation.GetBytes(32); // AES-256 + var iv = keyDerivation.GetBytes(16); // AES block size + + // Encrypt using AES-256-CBC: + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var cipherText = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); + + // Combine salt and ciphertext + var combined = new byte[SALT_LENGTH + cipherText.Length]; + Array.Copy(salt, 0, combined, 0, SALT_LENGTH); + Array.Copy(cipherText, 0, combined, SALT_LENGTH, cipherText.Length); + + // Encode to base64 and add the prefix: + encryptedValue = PREFIX + Convert.ToBase64String(combined); + return true; + } + catch (CryptographicException ex) + { + this.logger.LogWarning(ex, "Failed to encrypt value."); + return false; + } + } + + /// + /// Generates a new random 32-byte secret key and returns it as a base64 string. + /// + /// A base64-encoded 32-byte secret key. + public static string GenerateSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); +} diff --git a/app/MindWork AI Studio/Tools/ISecretId.cs b/app/MindWork AI Studio/Tools/ISecretId.cs index c1198913..42ee817f 100644 --- a/app/MindWork AI Studio/Tools/ISecretId.cs +++ b/app/MindWork AI Studio/Tools/ISecretId.cs @@ -5,6 +5,13 @@ namespace AIStudio.Tools; /// public interface ISecretId { + /// + /// Prefix used for secrets imported from enterprise configuration plugins. + /// This helps distinguish enterprise-managed keys from user-added keys + /// in the OS keyring. + /// + public const string ENTERPRISE_KEY_PREFIX = "config-plugin"; + /// /// The unique ID of the secret. /// diff --git a/app/MindWork AI Studio/Tools/LuaTools.cs b/app/MindWork AI Studio/Tools/LuaTools.cs new file mode 100644 index 00000000..0df50cd0 --- /dev/null +++ b/app/MindWork AI Studio/Tools/LuaTools.cs @@ -0,0 +1,16 @@ +namespace AIStudio.Tools; + +public static class LuaTools +{ + public static string EscapeLuaString(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs new file mode 100644 index 00000000..5f1cb58b --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs @@ -0,0 +1,49 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a pending API key that needs to be stored in the OS keyring. +/// This is used during plugin loading to collect API keys from configuration plugins +/// before storing them asynchronously. +/// +/// The secret ID (provider ID). +/// The secret name (provider instance name). +/// The decrypted API key. +/// The type of secret store to use. +public sealed record PendingEnterpriseApiKey( + string SecretId, + string SecretName, + string ApiKey, + SecretStoreType StoreType); + +/// +/// Static container for pending API keys during plugin loading. +/// +public static class PendingEnterpriseApiKeys +{ + private static readonly List PENDING_KEYS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending API key to the list. + /// + /// The pending API key to add. + public static void Add(PendingEnterpriseApiKey key) + { + lock (LOCK) + PENDING_KEYS.Add(key); + } + + /// + /// Gets and clears all pending API keys. + /// + /// A list of all pending API keys. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var keys = PENDING_KEYS.ToList(); + PENDING_KEYS.Clear(); + return keys; + } + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 29d95e76..a8e10d5d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -1,4 +1,5 @@ using AIStudio.Settings; +using AIStudio.Tools.Services; using Lua; @@ -8,7 +9,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration)); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); - + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration)); + private List configObjects = []; /// @@ -23,11 +25,50 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT if (!dryRun) { + // Store any decrypted API keys from enterprise configuration in the OS keyring: + await StoreEnterpriseApiKeysAsync(); + await SETTINGS_MANAGER.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } + /// + /// Stores any pending enterprise API keys in the OS keyring. + /// + private static async Task StoreEnterpriseApiKeysAsync() + { + var pendingKeys = PendingEnterpriseApiKeys.GetAndClear(); + if (pendingKeys.Count == 0) + return; + + LOG.LogInformation($"Storing {pendingKeys.Count} enterprise API key(s) in the OS keyring."); + var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); + foreach (var pendingKey in pendingKeys) + { + try + { + // Create a temporary secret ID object for storing the key: + var secretId = new TemporarySecretId(pendingKey.SecretId, pendingKey.SecretName); + var result = await rustService.SetAPIKey(secretId, pendingKey.ApiKey, pendingKey.StoreType); + + if (result.Success) + LOG.LogDebug($"Successfully stored enterprise API key for '{pendingKey.SecretName}' in the OS keyring."); + else + LOG.LogWarning($"Failed to store enterprise API key for '{pendingKey.SecretName}': {result.Issue}"); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Exception while storing enterprise API key for '{pendingKey.SecretName}'."); + } + } + } + + /// + /// Temporary implementation of ISecretId for storing enterprise API keys. + /// + private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId; + /// /// Tries to initialize the UI text content of the plugin. /// @@ -60,6 +101,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: allow the user to add providers? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun); + + // Config: show administration settings? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun); // Config: preview features visibility ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun); @@ -100,4 +144,4 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT message = string.Empty; return true; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 647c79bf..ffc6f5c0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Services; using Lua; @@ -13,6 +14,7 @@ namespace AIStudio.Tools.PluginSystem; /// public sealed record PluginConfigurationObject { + private static readonly RustService RUST_SERVICE = Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); @@ -159,7 +161,7 @@ public sealed record PluginConfigurationObject return true; } - + /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// @@ -168,37 +170,45 @@ public sealed record PluginConfigurationObject /// A selection expression to retrieve the configuration objects from the main configuration. /// A list of currently available plugins. /// A list of all existing configuration objects. + /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. /// Returns true if the configuration was altered during cleanup; otherwise, false. - public static bool CleanLeftOverConfigurationObjects( + public static async Task CleanLeftOverConfigurationObjects( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, - IList configObjectList) where TClass : IConfigurationObject + IList configObjectList, + SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject { var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData); var leftOverObjects = new List(); foreach (var configuredObject in configuredObjects) { + // Only process objects that are based on enterprise configuration plugins (aka configuration plugins), + // as only those can be left over after a plugin was removed: if(!configuredObject.IsEnterpriseConfiguration) continue; + // From what plugin is this configuration object coming from? var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId; if(configObjectSourcePluginId == Guid.Empty) continue; + // Is the source plugin still available? If not, we can be pretty sure that this configuration object is left + // over and should be removed: var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId); if(templateSourcePlugin is null) { - LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing the chat template from the settings."); + LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing this object from the settings."); leftOverObjects.Add(configuredObject); } + // Is the configuration object still present in the configuration plugin? If not, it is also left over and should be removed: if(!configObjectList.Any(configObject => configObject.Type == configObjectType && configObject.ConfigPluginId == configObjectSourcePluginId && configObject.Id.ToString() == configuredObject.Id)) { - LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the chat template from the settings."); + LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the object from the settings."); leftOverObjects.Add(configuredObject); } } @@ -206,8 +216,20 @@ public sealed record PluginConfigurationObject // Remove collected items after enumeration to avoid modifying the collection during iteration: var wasConfigurationChanged = leftOverObjects.Count > 0; foreach (var item in leftOverObjects.Distinct()) + { configuredObjects.Remove(item); + // Delete the API key from the OS keyring if the removed object has one: + if(secretStoreType is not null && item is ISecretId secretId) + { + var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value); + if (deleteResult.Success) + LOG.LogInformation($"Successfully deleted API key for removed enterprise provider '{item.Name}' from the OS keyring."); + else + LOG.LogWarning($"Failed to delete API key for removed enterprise provider '{item.Name}' from the OS keyring: {deleteResult.Issue}"); + } + } + return wasConfigurationChanged; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index b2b45aba..4bfbe3a3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -131,26 +131,26 @@ public static partial class PluginFactory // // Check LLM providers: - var wasConfigurationChanged = PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList); + var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER); // Check transcription providers: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER)) wasConfigurationChanged = true; // Check embedding providers: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) wasConfigurationChanged = true; // Check chat templates: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; - + // Check profiles: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; // Check document analysis policies: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; // Check for a preselected profile: @@ -168,6 +168,10 @@ public static partial class PluginFactory // Check for users allowed to added providers: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + + // Check for admin settings visibility: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; // Check for preview visibility: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS)) @@ -253,4 +257,4 @@ public static partial class PluginFactory return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio."); } } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index 1fd30fb2..2c20ede0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -18,6 +18,29 @@ public static partial class PluginFactory public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN; + /// + /// Gets the enterprise encryption instance for decrypting API keys in configuration plugins. + /// + public static EnterpriseEncryption? EnterpriseEncryption { get; private set; } + + /// + /// Initializes the enterprise encryption service by reading the encryption secret + /// from the Windows Registry or environment variables. + /// + /// The Rust service to use for reading the encryption secret. + public static async Task InitializeEnterpriseEncryption(Services.RustService rustService) + { + LOG.LogInformation("Initializing enterprise encryption service..."); + var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret(); + var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger(); + EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret); + + if (EnterpriseEncryption.IsAvailable) + LOG.LogInformation("Enterprise encryption service is available."); + else + LOG.LogWarning("Enterprise encryption service is not available (no secret configured)."); + } + /// /// Set up the plugin factory. We will read the data directory from the settings manager. /// Afterward, we will create the plugins directory and the internal plugin directory. diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index 76931c0b..004d445a 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -65,4 +65,24 @@ public sealed partial class RustService var serverUrl = await result.Content.ReadAsStringAsync(); return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl; } + + /// + /// Tries to read the enterprise environment for the configuration encryption secret. + /// + /// + /// Returns an empty string when the environment is not set or the request fails. + /// Otherwise, the base64-encoded encryption secret. + /// + public async Task EnterpriseEnvConfigEncryptionSecret() + { + var result = await this.http.GetAsync("/system/enterprise/config/encryption_secret"); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to query the enterprise configuration encryption secret: '{result.StatusCode}'"); + return string.Empty; + } + + var encryptionSecret = await result.Content.ReadAsStringAsync(); + return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index dba56e5c..7dff471e 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -62,6 +62,16 @@ "MudBlazor": "8.11.0" } }, + "Qdrant.Client": { + "type": "Direct", + "requested": "[1.16.1, )", + "resolved": "1.16.1", + "contentHash": "EJo50JXTdjY2JOUphCFLXoHukI/tz/ykLCmMnQHUjsKT22ZfL0XIdEziHOC3vjw2SOoY8WDVQ+AxixEonejOZA==", + "dependencies": { + "Google.Protobuf": "3.31.0", + "Grpc.Net.Client": "2.71.0" + } + }, "ReverseMarkdown": { "type": "Direct", "requested": "[5.0.0, )", @@ -76,6 +86,33 @@ "resolved": "3.2.449", "contentHash": "uA9sYDy4VepL3xwzBTLcP2LyuVYMt0ZIT3gaSiXvGoX15Ob+rOP+hGydhevlSVd+rFo+Y+VQFEHDuWU8HBW+XA==" }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.31.0", + "contentHash": "OZXSf6igaJBeo+kAzMhYF0R5zp0nRgf4G0Uis/IsGKACc4RGP9bQPLpHLengIFuASl0lY92utMB8rRpTx4TaOg==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "QquqUC37yxsDzd1QaDRsH2+uuznWPTS8CVE2Yzwl3CvU4geTNkolQXoVN812M2IwT6zpv3jsZRc9ExJFNFslTg==" + }, + "Grpc.Net.Client": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "U1vr20r5ngoT9nlb7wejF28EKN+taMhJsV9XtK9MkiepTZwnKxxiarriiMfCHuDAfPUm9XUjFMn/RIuJ4YY61w==", + "dependencies": { + "Grpc.Net.Common": "2.71.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0" + } + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "v0c8R97TwRYwNXlC8GyRXwYTCNufpDfUtj9la+wUrZFzVWkFJuNAltU+c0yI3zu0jl54k7en6u2WKgZgd57r2Q==", + "dependencies": { + "Grpc.Core.Api": "2.71.0" + } + }, "Markdig": { "type": "Transitive", "resolved": "0.41.3", 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 e4b9137b..977f7da9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -1,2 +1,6 @@ # v26.2.2, build 234 (2026-02-xx xx:xx UTC) -- Added a vector database (Qdrant) as a building block for our local RAG (retrieval-augmented generation) solution. Thank you very much, Paul (`PaulKoudelka`), for this major contribution. Note that our local RAG implementation remained in preview and has not yet been released; other building blocks are not yet ready. \ No newline at end of file +- Added a vector database (Qdrant) as a building block for our local RAG (retrieval-augmented generation) solution. Thank you very much, Paul (`PaulKoudelka`), for this major contribution. Note that our local RAG implementation remained in preview and has not yet been released; other building blocks are not yet ready. +- 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. +- 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. \ No newline at end of file diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 396308b8..39d4fbd2 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -27,6 +27,8 @@ 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_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. + 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. @@ -82,14 +84,61 @@ The latest example of an AI Studio configuration via configuration plugin can al Please note that the icon must be an SVG vector graphic. Raster graphics like PNGs, GIFs, and others aren’t supported. You can use the sample icon, which looks like a gear. Currently, you can configure the following things: -- Any number of self-hosted LLM providers (a combination of server and model), but currently only without API keys +- Any number of LLM providers (self-hosted or cloud providers with encrypted API keys) +- Any number of transcription providers for voice-to-text functionality +- Any number of embedding providers for RAG - The update behavior of AI Studio +- Various UI and feature settings (see the example configuration for details) All other settings can be made by the user themselves. If you need additional settings, feel free to create an issue in our planning repository: https://github.com/MindWorkAI/Planning/issues -In the coming months, we will allow more settings, such as: -- Using API keys for providers -- Configuration of embedding providers for RAG -- Configuration of data sources for RAG -- Configuration of chat templates -- Configuration of assistant plugins (for example, your own assistants for your company or specific departments) \ No newline at end of file +## Encrypted API Keys + +You can include encrypted API keys in your configuration plugins for cloud providers (like OpenAI, Anthropic) or secured on-premise models. This feature provides obfuscation to prevent casual exposure of API keys in configuration files. + +**Important Security Note:** This is obfuscation, not absolute security. Users with administrative access to their machines can potentially extract the decrypted API keys with sufficient effort. This feature is designed to: +- Prevent API keys from being visible in plaintext in configuration files +- Protect against accidental exposure when sharing or reviewing configurations +- Add a barrier against casual snooping + +### Setting Up Encrypted API Keys + +1. **Generate an encryption secret:** + In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string. + +2. **Deploy the encryption secret:** + Distribute the secret to all client machines via Group Policy (Windows Registry) or environment variables: + - Registry: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret` + - Environment: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` + + You must also deploy the same secret on the machine where you will export the encrypted API keys (step 3). + +3. **Export encrypted API keys from AI Studio:** + Once the encryption secret is deployed on your machine: + - Configure a provider with an API key in AI Studio's settings + - Click the export button for that provider + - If an API key is configured, you will be asked if you want to include the encrypted API key in the export + - The exported Lua code will contain the encrypted API key in the format `ENC:v1:` + +4. **Add encrypted keys to your configuration:** + Copy the exported configuration (including the encrypted API key) into your configuration plugin. + +### Example Configuration with Encrypted API Key + +```lua +CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { + ["Id"] = "9072b77d-ca81-40da-be6a-861da525ef7b", + ["InstanceName"] = "Corporate OpenAI GPT-4", + ["UsedLLMProvider"] = "OPEN_AI", + ["Host"] = "NONE", + ["Hostname"] = "", + ["APIKey"] = "ENC:v1:MTIzNDU2Nzg5MDEyMzQ1NkFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFla...", + ["AdditionalJsonApiParameters"] = "", + ["Model"] = { + ["Id"] = "gpt-4", + ["DisplayName"] = "GPT-4", + } +} +``` + +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 diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 48fb43c3..24a20bde 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -1046,6 +1046,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1056,6 +1065,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1063,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.5", "winapi", ] @@ -1073,6 +1094,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.6.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2082,7 +2113,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -2781,6 +2812,7 @@ dependencies = [ "cfg-if", "cipher", "crossbeam-channel", + "dirs", "file-format", "flexi_logger", "futures", @@ -2803,9 +2835,11 @@ dependencies = [ "sha2", "strum_macros", "sys-locale", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-window-state", + "tempfile", "tokio", "tokio-stream", "tracing-subscriber", @@ -2942,6 +2976,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -3084,9 +3127,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -3105,11 +3148,12 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.6.0", + "dispatch2", "objc2", ] @@ -3142,6 +3186,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.0" @@ -3256,6 +3310,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.0" @@ -3956,6 +4016,17 @@ dependencies = [ "thiserror 1.0.63", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.23" @@ -4897,6 +4968,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe840c5b1afe259a5657392a4dbb74473a14c8db999c3ec2f4ae812e028a94da" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5008,7 +5093,7 @@ dependencies = [ "unicode-segmentation", "uuid", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", "x11-dl", ] @@ -6028,7 +6113,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", ] [[package]] @@ -6125,7 +6210,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a" dependencies = [ - "windows-implement", + "windows-implement 0.39.0", "windows_aarch64_msvc 0.39.0", "windows_i686_gnu 0.39.0", "windows_i686_msvc 0.39.0", @@ -6142,6 +6227,18 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + [[package]] name = "windows-bindgen" version = "0.39.0" @@ -6152,6 +6249,15 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -6161,6 +6267,30 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.39.0" @@ -6171,6 +6301,28 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -6189,6 +6341,16 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -6379,6 +6541,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-tokens" version = "0.39.0" @@ -6728,7 +6899,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", ] [[package]] diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 8c484ab8..6203cac0 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -119,6 +119,30 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { ) } +#[get("/system/enterprise/config/encryption_secret")] +pub fn read_enterprise_env_config_encryption_secret(_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: + // - config_encryption_secret + // + // The environment variable is: + // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET + // + debug!("Trying to read the enterprise environment for the config encryption secret."); + get_enterprise_configuration( + "config_encryption_secret", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET", + ) +} + 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 77f4f032..647259f3 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -85,6 +85,7 @@ pub fn start_runtime_api() { 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::file_data::extract_data, crate::log::get_log_paths, crate::log::log_event, From 7ae8f7b5eddb8c5db5eb4d7dc325298724518ce0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 12:37:45 +0100 Subject: [PATCH 02/11] Upgraded dependencies (#655) --- .../wwwroot/changelog/v26.2.2.md | 3 +- runtime/Cargo.lock | 110 +++++++----------- runtime/Cargo.toml | 10 +- 3 files changed, 47 insertions(+), 76 deletions(-) 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 977f7da9..29f64e51 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -3,4 +3,5 @@ - 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. -- 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. \ No newline at end of file +- 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. +- Upgraded dependencies. \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 24a20bde..5fad3be7 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -197,9 +197,12 @@ dependencies = [ [[package]] name = "atoi_simd" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4790f9e8961209112beb783d85449b508673cf4a6a419c8449b210743ac4dbe9" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" +dependencies = [ + "debug_unsafe", +] [[package]] name = "atomic" @@ -406,9 +409,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -458,9 +461,9 @@ dependencies = [ [[package]] name = "calamine" -version = "0.32.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41bdeb83af82cd9cb686a19ed7efc2f50a21c262610f51ce945a8528860725ce" +checksum = "96ae094b353c7810cd5efd2e69413ebb9354816138a387c09f7b90d4e826a49f" dependencies = [ "atoi_simd", "byteorder", @@ -470,7 +473,7 @@ dependencies = [ "log", "quick-xml 0.38.4", "serde", - "zip 4.2.0", + "zip 7.4.0", ] [[package]] @@ -789,9 +792,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -948,6 +951,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "debug_unsafe" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b" + [[package]] name = "deflate64" version = "0.1.9" @@ -970,12 +979,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1046,15 +1055,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs-next" version = "2.0.0" @@ -1065,18 +1065,6 @@ dependencies = [ "dirs-sys-next", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1084,7 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users 0.4.5", + "redox_users", "winapi", ] @@ -2807,12 +2795,11 @@ dependencies = [ "arboard", "async-stream", "base64 0.22.1", + "bytes", "calamine", "cbc", "cfg-if", "cipher", - "crossbeam-channel", - "dirs", "file-format", "flexi_logger", "futures", @@ -2828,7 +2815,6 @@ dependencies = [ "rand_chacha 0.9.0", "rcgen", "reqwest 0.13.1", - "ring", "rocket", "serde", "serde_json", @@ -2840,10 +2826,9 @@ dependencies = [ "tauri-build", "tauri-plugin-window-state", "tempfile", + "time", "tokio", "tokio-stream", - "tracing-subscriber", - "url", "windows-registry 0.6.1", ] @@ -3029,9 +3014,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3310,12 +3295,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "os_pipe" version = "1.2.0" @@ -4016,17 +3995,6 @@ dependencies = [ "thiserror 1.0.63", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror 2.0.12", -] - [[package]] name = "ref-cast" version = "1.0.23" @@ -5433,30 +5401,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa 1.0.11", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5748,6 +5716,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + [[package]] name = "typenum" version = "1.17.0" @@ -7144,15 +7118,15 @@ dependencies = [ [[package]] name = "zip" -version = "4.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap 2.7.0", "memchr", + "typed-path", "zopfli", ] diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index b7a4bac0..835e632f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -34,7 +34,7 @@ hmac = "0.12.1" sha2 = "0.10.8" rcgen = { version = "0.14.7", features = ["pem"] } file-format = "0.28.0" -calamine = "0.32.0" +calamine = "0.33.0" pdfium-render = "0.8.37" sys-locale = "0.3.2" cfg-if = "1.0.4" @@ -44,12 +44,8 @@ strum_macros = "0.27" sysinfo = "0.38.0" # Fixes security vulnerability downstream, where the upstream is not fixed yet: -url = "2.5.8" -ring = "0.17.14" -crossbeam-channel = "0.5.15" -tracing-subscriber = "0.3.20" -dirs = "6.0.0" - +time = "0.3.47" # -> Rocket +bytes = "1.11.1" # -> almost every dependency [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 From 50d4e7e6dcaae77b362e7982da537c9fac5f4030 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 13:04:19 +0100 Subject: [PATCH 03/11] Fixed workspace delete color (#656) --- app/MindWork AI Studio/Components/Workspaces.razor | 6 +++--- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index bdeb52ae..80a81f60 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -36,7 +36,7 @@ - + @@ -57,7 +57,7 @@ - + @@ -90,4 +90,4 @@ break; } - \ 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 29f64e51..0e024e75 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -4,4 +4,5 @@ - 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. - 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. - Upgraded dependencies. \ No newline at end of file From 4eb58eb56d3751902f06af4c9f1f125c98ca8255 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 17:27:33 +0100 Subject: [PATCH 04/11] Fixed manual chat saving during streaming (#657) --- app/MindWork AI Studio/Components/ChatComponent.razor | 2 +- app/MindWork AI Studio/Components/ChatComponent.razor.cs | 4 ++++ app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 38409675..52b82b9b 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -66,7 +66,7 @@ @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) { - + } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index ed37e54d..c7bd4dce 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -912,6 +912,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable break; case Event.CHAT_STREAMING_DONE: + // Streaming mutates the last AI block over time. + // In manual storage mode, a save during streaming must not + // mark the final streamed state as already persisted. + this.hasUnsavedChanges = true; if(this.autoSaveEnabled) await this.SaveThread(); break; 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 0e024e75..598bb828 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -5,4 +5,5 @@ - 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. - 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. +- 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. - Upgraded dependencies. \ No newline at end of file From 891b90819b639ba17ac17e771f46448ec7746b91 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 17:46:38 +0100 Subject: [PATCH 05/11] Localized database information (#658) --- .../Assistants/I18N/allTexts.lua | 15 +++++++++++ .../Pages/Information.razor | 2 +- .../plugin.lua | 15 +++++++++++ .../plugin.lua | 15 +++++++++++ .../Tools/Databases/DatabaseClient.cs | 10 +++---- .../Qdrant/QdrantClientImplementation.cs | 26 +++++++++---------- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index e1188a53..ef169b14 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5833,6 +5833,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Storage size" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Reported version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Number of collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment." diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 32238d06..b857f80d 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -24,7 +24,7 @@ @this.VersionDatabase - + @foreach (var item in this.databaseDisplayInfo) { 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 9612b2ef..7d3503d2 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 @@ -5835,6 +5835,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Allen LLM-Anbietern vertrauen" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Speichergröße" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP-Port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Gemeldete Version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC-Port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Anzahl der Collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "Die zugehörigen Daten dürfen an keinen LLM-Anbieter gesendet werden. Das bedeutet, dass diese Datenquelle momentan nicht verwendet werden kann." 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 4be2328f..6c2e30cf 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 @@ -5835,6 +5835,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Storage size" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Reported version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Number of collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment." diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs index 5ea457ec..b50aafe1 100644 --- a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs @@ -5,12 +5,12 @@ public abstract class DatabaseClient(string name, string path) public string Name => name; private string Path => path; - - protected ILogger? logger; + + private ILogger? logger; public abstract IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo(); - public string GetStorageSize() + protected string GetStorageSize() { if (string.IsNullOrWhiteSpace(this.Path)) { @@ -28,8 +28,8 @@ public abstract class DatabaseClient(string name, string path) var size = files.Sum(file => new FileInfo(file).Length); return FormatBytes(size); } - - public static string FormatBytes(long size) + + private static string FormatBytes(long size) { string[] suffixes = { "B", "KB", "MB", "GB", "TB", "PB" }; int suffixIndex = 0; diff --git a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs index 77ae3636..25f37253 100644 --- a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs @@ -1,10 +1,13 @@ using Qdrant.Client; using Qdrant.Client.Grpc; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.Databases.Qdrant; public class QdrantClientImplementation : DatabaseClient { + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(QdrantClientImplementation).Namespace, nameof(QdrantClientImplementation)); + private int HttpPort { get; } private int GrpcPort { get; } @@ -25,8 +28,8 @@ public class QdrantClientImplementation : DatabaseClient } private const string IP_ADDRESS = "localhost"; - - public QdrantClient CreateQdrantClient() + + private QdrantClient CreateQdrantClient() { var address = "https://" + IP_ADDRESS + ":" + this.GrpcPort; var channel = QdrantChannel.ForAddress(address, new ClientConfiguration @@ -38,13 +41,13 @@ public class QdrantClientImplementation : DatabaseClient return new QdrantClient(grpcClient); } - public async Task GetVersion() + private async Task GetVersion() { var operation = await this.GrpcClient.HealthAsync(); return "v"+operation.Version; } - public async Task GetCollectionsAmount() + private async Task GetCollectionsAmount() { var operation = await this.GrpcClient.ListCollectionsAsync(); return operation.Count.ToString(); @@ -52,15 +55,12 @@ public class QdrantClientImplementation : DatabaseClient public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() { - yield return ("HTTP port", this.HttpPort.ToString()); - yield return ("gRPC port", this.GrpcPort.ToString()); - yield return ("Extracted version", await this.GetVersion()); - yield return ("Storage size", $"{base.GetStorageSize()}"); - yield return ("Amount of collections", await this.GetCollectionsAmount()); + yield return (TB("HTTP port"), this.HttpPort.ToString()); + yield return (TB("gRPC port"), this.GrpcPort.ToString()); + yield return (TB("Reported version"), await this.GetVersion()); + yield return (TB("Storage size"), $"{this.GetStorageSize()}"); + yield return (TB("Number of collections"), await this.GetCollectionsAmount()); } - public override void Dispose() - { - this.GrpcClient.Dispose(); - } + public override void Dispose() => this.GrpcClient.Dispose(); } \ No newline at end of file From ea4e3f0199e7c413c8fc9df99c75fcef410e84fd Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 10 Feb 2026 15:23:56 +0100 Subject: [PATCH 06/11] Render source URL for plugins (#660) --- .../Assistants/I18N/allTexts.lua | 15 +++++-- app/MindWork AI Studio/Pages/Plugins.razor | 36 ++++++++++++---- app/MindWork AI Studio/Pages/Plugins.razor.cs | 4 +- .../Plugins/configuration/plugin.lua | 7 ++- .../plugin.lua | 15 +++++-- .../plugin.lua | 15 +++++-- .../Tools/PluginSystem/PluginBase.cs | 43 ++++++++++++++++++- .../wwwroot/changelog/v26.2.2.md | 1 + 8 files changed, 115 insertions(+), 21 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ef169b14..becac5b0 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5305,6 +5305,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" @@ -5317,6 +5320,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings" @@ -6037,15 +6043,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "The -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "The field DESCRIPTION does not exist or is not a valid string." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X)." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "The table AUTHORS is empty. At least one author must be specified." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string." diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index b5a39ef4..c1012744 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -63,15 +63,35 @@ - @if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION }) - { - var isEnabled = this.SettingsManager.IsPluginEnabled(context); - - - - } + + @if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION }) + { + var isEnabled = this.SettingsManager.IsPluginEnabled(context); + + + + } + + @if (context is { IsInternal: false } && !string.IsNullOrWhiteSpace(context.SourceURL)) + { + var sourceUrl = context.SourceURL; + var isSendingMail = IsSendingMail(sourceUrl); + if(isSendingMail) + { + + + + } + else + { + + + + } + } + - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 4eb6078c..36de6366 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -49,6 +49,8 @@ public partial class Plugins : MSGComponentBase await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); #region Overrides of MSGComponentBase @@ -63,4 +65,4 @@ public partial class Plugins : MSGComponentBase } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 3e12a567..4c37375a 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -30,7 +30,12 @@ AUTHORS = {""} -- The support contact for the plugin: SUPPORT_CONTACT = "" --- The source URL for the plugin: +-- The source URL for the plugin. Can be a HTTP(S) URL or an mailto link. +-- You may link to an internal documentation page, a Git repository, or +-- to a support or wiki page. +-- +-- A mailto link could look like: +-- SOURCE_URL = "mailto:helpdesk@company.org?subject=Help" SOURCE_URL = "" -- The categories for the plugin: 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 7d3503d2..a5d01438 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 @@ -5307,6 +5307,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Interne Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Deaktivierte Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "E-Mail senden" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren" @@ -5319,6 +5322,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Aktivierte Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Aktionen" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Website öffnen" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Einstellungen" @@ -6039,15 +6045,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "Das -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "Das Feld DESCRIPTION existiert nicht oder ist keine gültige Zeichenkette." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "Das Feld SOURCE_URL ist keine gültige URL. Die URL muss mit 'http://' oder 'https://' beginnen." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "Das Feld VERSION ist keine gültige Versionsnummer. Die Versionsnummer muss als Zeichenkette im Format major.minor.patch (X.X.X) angegeben werden." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "Das Feld SOURCE_URL ist keine gültige URL. Die URL muss mit „http://“, „https://“ oder „mailto:“ beginnen." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "Die Tabelle AUTHORS ist leer. Es muss mindestens ein Autor angegeben werden." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "Das Feld SOURCE_URL ist keine gültige URL. Wenn die URL mit „mailto:“ beginnt, muss sie eine gültige E-Mail-Adresse als Empfänger enthalten." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "Das Feld SUPPORT_CONTACT ist leer. Der Support-Kontakt muss eine nicht-leere Zeichenkette sein." 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 6c2e30cf..11e11579 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 @@ -5307,6 +5307,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" @@ -5319,6 +5322,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings" @@ -6039,15 +6045,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "The -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "The field DESCRIPTION does not exist or is not a valid string." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X)." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "The table AUTHORS is empty. At least one author must be specified." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string." diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs index e6e8707a..afff3d35 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs @@ -332,16 +332,55 @@ public abstract partial class PluginBase : IPluginMetadata return false; } - if (!url.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) && !url.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase)) + url = url.Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var sourceUri)) { url = string.Empty; - message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'."); + message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); return false; } + + var isHttp = sourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + var isHttps = sourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + var isMailTo = sourceUri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase); + if (!isHttp && !isHttps && !isMailTo) + { + url = string.Empty; + message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); + return false; + } + + if (isMailTo) + { + var recipient = ExtractMailtoRecipient(url); + if (string.IsNullOrWhiteSpace(recipient)) + { + url = string.Empty; + message = TB("The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient."); + return false; + } + } + + url = sourceUri.ToString(); message = string.Empty; return true; } + + private static string ExtractMailtoRecipient(string rawUrl) + { + var separatorIndex = rawUrl.IndexOf(':'); + if (separatorIndex < 0 || separatorIndex + 1 >= rawUrl.Length) + return string.Empty; + + var schemeSpecificPart = rawUrl[(separatorIndex + 1)..]; + var queryStart = schemeSpecificPart.IndexOf('?'); + var recipient = queryStart >= 0 + ? schemeSpecificPart[..queryStart] + : schemeSpecificPart; + + return recipient.Trim(); + } /// /// Tries to read the categories of the plugin. 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 598bb828..d0f5dc9a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -5,5 +5,6 @@ - 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. - 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. - 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. - Upgraded dependencies. \ No newline at end of file From 48f8cb32857ff2c1b1b1f89ac9f7fcc594add159 Mon Sep 17 00:00:00 2001 From: Oliver Kunc <36070570+OliverKunc@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:37:36 +0100 Subject: [PATCH 07/11] Fixed OpenAI Responses API by not discarding whitespace (#661) Co-authored-by: Thorsten Sommer --- .../Provider/OpenAI/ResponsesDeltaStreamLine.cs | 6 +++--- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs index 5bad9c1b..0c1767d0 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs @@ -7,15 +7,15 @@ namespace AIStudio.Provider.OpenAI; /// The delta content of the response. public record ResponsesDeltaStreamLine( string Type, - string Delta) : IResponseStreamLine + string? Delta) : IResponseStreamLine { #region Implementation of IResponseStreamLine /// - public bool ContainsContent() => !string.IsNullOrWhiteSpace(this.Delta); + public bool ContainsContent() => this.Delta is not null; /// - public ContentStreamChunk GetContent() => new(this.Delta, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources()); // // Please note that there are multiple options where LLM providers might stream sources: 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 d0f5dc9a..abbe60f4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -7,4 +7,5 @@ - 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. - 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 From 3671444d2862f9630b998ef4411f89575f335a32 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 15 Feb 2026 18:11:57 +0100 Subject: [PATCH 08/11] 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, From 53f657da71ff0e88f3db554286958b3379e74938 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 12:13:36 +0100 Subject: [PATCH 09/11] Improved the user language detection, especially for Linux (#663) --- .../Settings/SettingsManager.cs | 44 +++++-- .../wwwroot/changelog/v26.2.2.md | 1 + runtime/src/environment.rs | 115 +++++++++++++++++- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index 3b4ea704..d4bfc7e3 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -172,17 +172,31 @@ public sealed class SettingsManager { case LangBehavior.AUTO: var languageCode = await this.rustService.ReadUserLanguage(); - var languagePlugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x is ILanguagePlugin langPlug && langPlug.IETFTag == languageCode); - if (languagePlugin is null) + var languagePlugins = PluginFactory.RunningPlugins.OfType().ToList(); + + if (!string.IsNullOrWhiteSpace(languageCode)) { - this.logger.LogWarning($"The language plugin for the language '{languageCode}' is not available."); - return PluginFactory.BaseLanguage; + var exactMatch = languagePlugins.FirstOrDefault(x => string.Equals(x.IETFTag, languageCode, StringComparison.OrdinalIgnoreCase)); + if (exactMatch is not null) + return exactMatch; + + var primaryLanguage = GetPrimaryLanguage(languageCode); + if (!string.IsNullOrWhiteSpace(primaryLanguage)) + { + var primaryLanguageMatch = languagePlugins + .Where(x => string.Equals(GetPrimaryLanguage(x.IETFTag), primaryLanguage, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.IETFTag, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (primaryLanguageMatch is not null) + { + this.logger.LogWarning($"No exact language plugin found for '{languageCode}'. Use language fallback '{primaryLanguageMatch.IETFTag}'."); + return primaryLanguageMatch; + } + } } - if (languagePlugin is ILanguagePlugin langPlugin) - return langPlugin; - - this.logger.LogError("The language plugin is not a language plugin."); + this.logger.LogWarning($"The language plugin for the language '{languageCode}' (normalized='{languageCode}') is not available."); return PluginFactory.BaseLanguage; case LangBehavior.MANUAL: @@ -204,6 +218,18 @@ public sealed class SettingsManager this.logger.LogError("The language behavior is unknown."); return PluginFactory.BaseLanguage; } + + private static string GetPrimaryLanguage(string localeTag) + { + if (string.IsNullOrWhiteSpace(localeTag)) + return string.Empty; + + var separatorIndex = localeTag.IndexOf('-'); + if (separatorIndex < 0) + return localeTag; + + return localeTag[..separatorIndex]; + } [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false) @@ -365,4 +391,4 @@ public sealed class SettingsManager // Return the full name of the property, including the class name: return $"{typeof(TIn).Name}.{memberExpr.Member.Name}"; } -} \ 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 af8eff4b..5823c1cf 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -7,6 +7,7 @@ - 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. +- 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 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/runtime/src/environment.rs b/runtime/src/environment.rs index f3ccdc60..478fbff4 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -7,6 +7,8 @@ use serde::Serialize; use sys_locale::get_locale; use crate::api_token::APIToken; +const DEFAULT_LANGUAGE: &str = "en-US"; + /// The data directory where the application stores its data. pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); @@ -41,12 +43,115 @@ pub fn is_prod() -> bool { !is_dev() } +fn normalize_locale_tag(locale: &str) -> Option { + let trimmed = locale.trim(); + if trimmed.is_empty() { + return None; + } + + let without_encoding = trimmed + .split('.') + .next() + .unwrap_or(trimmed) + .split('@') + .next() + .unwrap_or(trimmed) + .trim(); + + if without_encoding.is_empty() { + return None; + } + + let normalized_delimiters = without_encoding.replace('_', "-"); + let mut segments = normalized_delimiters + .split('-') + .filter(|segment| !segment.is_empty()); + + let language = segments.next()?; + if language.eq_ignore_ascii_case("c") || language.eq_ignore_ascii_case("posix") { + return None; + } + + let language = language.to_ascii_lowercase(); + if language.len() < 2 || !language.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + if let Some(region) = segments.next() { + if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) { + return Some(format!("{}-{}", language, region.to_ascii_uppercase())); + } + } + + Some(language) +} + +#[cfg(target_os = "linux")] +fn read_locale_from_environment() -> Option { + if let Ok(language) = env::var("LANGUAGE") { + for candidate in language.split(':') { + if let Some(locale) = normalize_locale_tag(candidate) { + info!("Detected user language from Linux environment variable 'LANGUAGE': '{}'.", locale); + return Some(locale); + } + } + } + + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = env::var(key) { + if let Some(locale) = normalize_locale_tag(&value) { + info!("Detected user language from Linux environment variable '{}': '{}'.", key, locale); + return Some(locale); + } + } + } + + None +} + +#[cfg(not(target_os = "linux"))] +fn read_locale_from_environment() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::normalize_locale_tag; + + #[test] + fn normalize_locale_tag_supports_common_linux_formats() { + assert_eq!(normalize_locale_tag("de_DE.UTF-8"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de_DE@euro"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de"), Some(String::from("de"))); + assert_eq!(normalize_locale_tag("en-US"), Some(String::from("en-US"))); + } + + #[test] + fn normalize_locale_tag_rejects_non_language_locales() { + assert_eq!(normalize_locale_tag("C"), None); + assert_eq!(normalize_locale_tag("C.UTF-8"), None); + assert_eq!(normalize_locale_tag("POSIX"), None); + assert_eq!(normalize_locale_tag(""), None); + } +} + #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { - get_locale().unwrap_or_else(|| { - warn!("Could not determine the system language. Use default 'en-US'."); - String::from("en-US") - }) + if let Some(locale) = get_locale() { + if let Some(normalized_locale) = normalize_locale_tag(&locale) { + info!("Detected user language from sys-locale: '{}'.", normalized_locale); + return normalized_locale; + } + + warn!("sys-locale returned an unusable locale value: '{}'.", locale); + } + + if let Some(locale) = read_locale_from_environment() { + return locale; + } + + warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE); + String::from(DEFAULT_LANGUAGE) } #[get("/system/enterprise/config/id")] @@ -300,4 +405,4 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { } } } -} \ No newline at end of file +} From 4562ed1c6af0672222ddd0f99dada585371173bf Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 13:04:16 +0100 Subject: [PATCH 10/11] Updated README and other texts (#664) --- README.md | 1 + .../Assistants/I18N/allTexts.lua | 28 +++++++++++++---- .../Components/Motivation.razor | 14 +++++++-- .../Components/Vision.razor.cs | 1 + app/MindWork AI Studio/Pages/Home.razor.cs | 1 + .../plugin.lua | 30 +++++++++++++++---- .../plugin.lua | 28 +++++++++++++---- 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 363ec8aa..624cbfc8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provi **Key advantages:** - **Free of charge**: The app is free to use, both for personal and commercial purposes. +- **Democratization of AI**: We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. - **Independence**: You are not tied to any single provider. Instead, you can choose the providers that best suit your needs. Right now, we support: - [OpenAI](https://openai.com/) (GPT5, GPT4.1, o1, o3, o4, etc.) - [Perplexity](https://www.perplexity.ai/) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 9e66386d..0c4509e8 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1804,21 +1804,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard" @@ -2434,9 +2440,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats." @@ -4981,6 +4993,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage" @@ -4990,6 +5005,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started" diff --git a/app/MindWork AI Studio/Components/Motivation.razor b/app/MindWork AI Studio/Components/Motivation.razor index a8242f22..eae68519 100644 --- a/app/MindWork AI Studio/Components/Motivation.razor +++ b/app/MindWork AI Studio/Components/Motivation.razor @@ -1,6 +1,6 @@ @inherits MSGComponentBase - @T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.") + @T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.") @@ -28,5 +28,13 @@ - @T("Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.") - \ No newline at end of file + @T("Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.") + + + + @T("Democratization of AI") + + + + @T("We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.") + diff --git a/app/MindWork AI Studio/Components/Vision.razor.cs b/app/MindWork AI Studio/Components/Vision.razor.cs index b81c084a..12a4f820 100644 --- a/app/MindWork AI Studio/Components/Vision.razor.cs +++ b/app/MindWork AI Studio/Components/Vision.razor.cs @@ -19,6 +19,7 @@ public partial class Vision : MSGComponentBase this.itemsVision = [ new(T("Meet your needs"), T("Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.")), + new(T("Democratization of AI"), T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.")), new(T("Integrating your data"), T("You'll be able to integrate your data into AI Studio, like your PDF or Office files, or your Markdown notes.")), new(T("Integration of enterprise data"), T("It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.")), new(T("Useful assistants"), T("We'll develop more assistants for everyday tasks.")), diff --git a/app/MindWork AI Studio/Pages/Home.razor.cs b/app/MindWork AI Studio/Pages/Home.razor.cs index 7facd6e3..610d22b0 100644 --- a/app/MindWork AI Studio/Pages/Home.razor.cs +++ b/app/MindWork AI Studio/Pages/Home.razor.cs @@ -31,6 +31,7 @@ public partial class Home : MSGComponentBase { this.itemsAdvantages = [ new(this.T("Free of charge"), this.T("The app is free to use, both for personal and commercial purposes.")), + new(this.T("Democratization of AI"), this.T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.")), new(this.T("Independence"), this.T("You are not tied to any single provider. Instead, you might choose the provider that best suits your needs. Right now, we support OpenAI (GPT5, o1, etc.), Perplexity, Mistral, Anthropic (Claude), Google Gemini, xAI (Grok), DeepSeek, Alibaba Cloud (Qwen), OpenRouter, Hugging Face, and self-hosted models using vLLM, llama.cpp, ollama, LM Studio, Groq, or Fireworks. For scientists and employees of research institutions, we also support Helmholtz and GWDG AI services. These are available through federated logins like eduGAIN to all 18 Helmholtz Centers, the Max Planck Society, most German, and many international universities.")), new(this.T("Assistants"), this.T("You just want to quickly translate a text? AI Studio has so-called assistants for such and other tasks. No prompting is necessary when working with these assistants.")), new(this.T("Unrestricted usage"), this.T("Unlike services like ChatGPT, which impose limits after intensive use, MindWork AI Studio offers unlimited usage through the providers API.")), 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 e8ef345b..d95f1a6a 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 @@ -1798,7 +1798,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T527187983"] = " UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T986578435"] = "Pandoc installieren" -- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologiestapel für den Bau solch robuster Anwendungen erwiesen." +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologie-Stack für den Bau solch robuster Anwendungen erwiesen." -- Limitations of Existing Solutions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränkungen bestehender Lösungen" @@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränku -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Persönliche Bedürfnisse und Einschränkungen von Webdiensten" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Demokratisierung von KI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "Während ich nach passenden Lösungen suchte, stieß ich auf eine Desktop-Anwendung namens Anything LLM. Leider konnte sie meine spezifischen Anforderungen nicht erfüllen und entsprach auch nicht dem Benutzeroberflächendesign, das ich mir vorgestellt hatte. Für macOS gab es zwar mehrere Apps, die meiner Vorstellung ähnelten, aber sie waren allesamt kostenpflichtige Lösungen mit unklarer Herkunft. Die Identität der Entwickler und die Ursprünge dieser Apps waren nicht ersichtlich, was erhebliche Sicherheitsbedenken hervorrief. Berichte von Nutzern über gestohlene API-Schlüssel und unerwünschte Abbuchungen verstärkten meine Bedenken zusätzlich." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der ursprüngliche Entwickler von MindWork AI Studio. Die Motivation zur Entwicklung dieser App entstand aus mehreren wichtigen Bedürfnissen und Beobachtungen, die ich im Laufe der Zeit gemacht habe." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Mit MindWork AI Studio möchte ich ein sicheres, flexibles und benutzerfreundliches Werkzeug bereitstellen, das für ein breites Publikum geeignet ist, ohne Kompromisse bei Funktionalität oder Design einzugehen. Diese App ist das Ergebnis meines Wunsches, persönliche Anforderungen zu erfüllen, bestehende Lücken auf dem Markt zu schließen und innovative Entwicklungsmethoden zu präsentieren." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "Wir möchten auch zur Demokratisierung von KI beitragen. MindWork AI Studio läuft selbst auf kostengünstiger Hardware, einschließlich Computern für rund 100 € wie dem Raspberry Pi. Dadurch sind die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Für Ihre ersten Schritte können Sie mit lokalen LLMs beginnen oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Sich auf Webdienste wie ChatGPT zu verlassen, war für mich keine nachhaltige Lösung. Ich brauchte eine KI, die auch direkt auf Dateien auf meinem Gerät zugreifen kann – eine Funktion, die Webdienste aus Sicherheits- und Datenschutzgründen grundsätzlich nicht bieten. Zwar hätte ich mir eine eigene Lösung in Python programmieren können, aber das wäre für den Alltag zu umständlich gewesen. Noch wichtiger war mir, eine Lösung zu entwickeln, die jeder nutzen kann, ganz ohne Programmierkenntnisse." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der initiale Entwickler von MindWork AI Studio. Ich habe dieses Projekt auf Grundlage von Bedürfnissen und Beobachtungen gestartet, die ich im Laufe der Zeit gemacht habe. Heute haben wir ein Kernteam von Entwicklern und Unterstützung aus der Open-Source-Community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Plattformübergreifende und moderne Entwicklung" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Heute verfolgt unser Team das Ziel, ein sicheres, flexibles und benutzerfreundliches Tool bereitzustellen, das eine breite Zielgruppe anspricht, ohne dabei Kompromisse bei Funktionalität oder dem Design einzugehen." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Kopiert den Inhalt in die Zwischenablage" @@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "Sie können ihre -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "Bald wird es möglich sein, Daten aus dem Firmennetzwerk über eine festgelegte Schnittstelle (External Retrieval Interface, kurz ERI) zu integrieren. Dafür wird voraussichtlich Entwicklungsaufwand seitens der jeweiligen Organisation nötig sein." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Demokratisierung von KI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Was auch immer ihr Beruf oder ihre Aufgabe ist, MindWork AI Studio möchte ihre Bedürfnisse erfüllen: Egal, ob Sie Projektmanager, Wissenschaftler, Künstler, Autor, Softwareentwickler oder Spieleentwickler sind." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "Sie können ihre E-Mail-Postfächer mit AI Studio verbinden. Die KI liest ihre E-Mails und benachrichtigt Sie über wichtige Ereignisse. Außerdem haben Sie in ihren Chats Zugriff auf das Wissen aus ihren E-Mails." @@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "Sie zahlen nur für das, -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistenten" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr vollständiger Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unbeschränkte Nutzung" @@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Einführung" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Demokratisierung von KI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Los geht's" 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 562cc2a6..688cb8d0 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 @@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard" @@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats." @@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage" @@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started" From 6e33c361dc4f17f551257435b65b690daf66be65 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 13:36:53 +0100 Subject: [PATCH 11/11] Improved single-input dialog (#665) --- .../Dialogs/SingleInputDialog.razor | 16 +++++++++++++--- .../Dialogs/SingleInputDialog.razor.cs | 16 +++++++++++++++- .../wwwroot/changelog/v26.2.2.md | 1 + 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor index 23c0537f..31f23f17 100644 --- a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor +++ b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor @@ -1,11 +1,21 @@ @inherits MSGComponentBase - + @this.Message - + @@ -16,4 +26,4 @@ @this.ConfirmText - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs index 01c5be54..c858b38c 100644 --- a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs @@ -1,6 +1,7 @@ using AIStudio.Components; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; namespace AIStudio.Dialogs; @@ -57,6 +58,19 @@ public partial class SingleInputDialog : MSGComponentBase private void Cancel() => this.MudDialog.Cancel(); + private async Task HandleUserInputKeyDown(KeyboardEventArgs keyEvent) + { + var key = keyEvent.Key.ToLowerInvariant(); + var code = keyEvent.Code.ToLowerInvariant(); + if (key is not "enter" && code is not "enter" and not "numpadenter") + return; + + if (keyEvent is { AltKey: true } or { CtrlKey: true } or { MetaKey: true }) + return; + + await this.Confirm(); + } + private async Task Confirm() { await this.form.Validate(); @@ -65,4 +79,4 @@ public partial class SingleInputDialog : MSGComponentBase this.MudDialog.Close(DialogResult.Ok(this.UserInput)); } -} \ 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 5823c1cf..1cab7ec4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -6,6 +6,7 @@ - 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 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 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.