From b445600a52bce6507a41e2fbb252407a0eb03f68 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 19 Feb 2026 20:43:47 +0100 Subject: [PATCH 01/14] Enhanced enterprise config support (#666) --- .../Components/ConfigInfoRow.razor | 10 + .../Components/ConfigInfoRow.razor.cs | 9 + .../Components/ConfigInfoRowItem.cs | 9 + .../Components/ConfigPluginInfoCard.razor | 21 ++ .../Components/ConfigPluginInfoCard.razor.cs | 24 +++ .../Components/EncryptionSecretInfo.razor | 15 ++ .../Components/EncryptionSecretInfo.razor.cs | 18 ++ .../Layout/MainLayout.razor.cs | 22 +- .../Pages/Information.razor | 196 ++++++++---------- .../Pages/Information.razor.cs | 26 ++- .../Plugins/configuration/plugin.lua | 3 + .../Settings/EmbeddingProvider.cs | 30 +-- app/MindWork AI Studio/Settings/Provider.cs | 34 +-- .../Settings/TranscriptionProvider.cs | 30 +-- .../Tools/PluginSystem/IAvailablePlugin.cs | 4 + .../Tools/PluginSystem/PluginConfiguration.cs | 13 ++ .../PluginSystem/PluginConfigurationObject.cs | 10 +- .../PluginSystem/PluginFactory.Download.cs | 86 ++++++-- .../PluginSystem/PluginFactory.HotReload.cs | 2 +- .../PluginSystem/PluginFactory.Internal.cs | 2 +- .../PluginSystem/PluginFactory.Loading.cs | 40 +++- .../PluginSystem/PluginFactory.Remove.cs | 129 +++++++++--- .../PluginSystem/PluginFactory.Starting.cs | 8 +- .../Tools/PluginSystem/PluginFactory.cs | 18 +- .../Tools/PluginSystem/PluginMetadata.cs | 8 +- .../Services/EnterpriseEnvironmentService.cs | 179 ++++++++-------- .../Tools/Services/RustService.Enterprise.cs | 38 +--- .../wwwroot/changelog/v26.2.2.md | 3 + documentation/Enterprise IT.md | 104 +++++++++- runtime/src/environment.rs | 66 +----- runtime/src/runtime_api.rs | 2 - 31 files changed, 733 insertions(+), 426 deletions(-) create mode 100644 app/MindWork AI Studio/Components/ConfigInfoRow.razor create mode 100644 app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs create mode 100644 app/MindWork AI Studio/Components/ConfigInfoRowItem.cs create mode 100644 app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor create mode 100644 app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs create mode 100644 app/MindWork AI Studio/Components/EncryptionSecretInfo.razor create mode 100644 app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs diff --git a/app/MindWork AI Studio/Components/ConfigInfoRow.razor b/app/MindWork AI Studio/Components/ConfigInfoRow.razor new file mode 100644 index 00000000..829f7ba5 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRow.razor @@ -0,0 +1,10 @@ +
+ + + @this.Item.Text + + @if (!string.IsNullOrWhiteSpace(this.Item.CopyValue)) + { + + } +
diff --git a/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs b/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs new file mode 100644 index 00000000..0a2de099 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class ConfigInfoRow : ComponentBase +{ + [Parameter] + public ConfigInfoRowItem Item { get; set; } = new(Icons.Material.Filled.ArrowRightAlt, string.Empty, string.Empty, string.Empty, string.Empty); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs b/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs new file mode 100644 index 00000000..ab700c53 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigInfoRowItem.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Components; + +public sealed record ConfigInfoRowItem( + string Icon, + string Text, + string CopyValue, + string CopyTooltip, + string Style = "" +); \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor new file mode 100644 index 00000000..4a3c8106 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor @@ -0,0 +1,21 @@ + +
+ + + @this.HeaderText + +
+ + @foreach (var item in this.Items) + { + + } + + @if (this.ShowWarning) + { +
+ + @this.WarningText +
+ } +
diff --git a/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs new file mode 100644 index 00000000..2fc224be --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class ConfigPluginInfoCard : ComponentBase +{ + [Parameter] + public string HeaderIcon { get; set; } = Icons.Material.Filled.Extension; + + [Parameter] + public string HeaderText { get; set; } = string.Empty; + + [Parameter] + public IEnumerable Items { get; set; } = []; + + [Parameter] + public bool ShowWarning { get; set; } + + [Parameter] + public string WarningText { get; set; } = string.Empty; + + [Parameter] + public string Class { get; set; } = "pa-3 mt-2 mb-2"; +} diff --git a/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor new file mode 100644 index 00000000..e05f9539 --- /dev/null +++ b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor @@ -0,0 +1,15 @@ + +
+ + @if (this.IsConfigured) + { + + @this.ConfiguredText + } + else + { + + @this.NotConfiguredText + } +
+
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs new file mode 100644 index 00000000..5fa1a5dd --- /dev/null +++ b/app/MindWork AI Studio/Components/EncryptionSecretInfo.razor.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class EncryptionSecretInfo : ComponentBase +{ + [Parameter] + public bool IsConfigured { get; set; } + + [Parameter] + public string ConfiguredText { get; set; } = string.Empty; + + [Parameter] + public string NotConfiguredText { get; set; } = string.Empty; + + [Parameter] + public string Class { get; set; } = "mt-2 mb-2"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 08005e68..07dfebd2 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -215,8 +215,28 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan .CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) .Where(env => env != default) .ToList(); + + var failedDeferredConfigIds = new HashSet(); foreach (var env in enterpriseEnvironments) - await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); + { + var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); + if (!wasDownloadSuccessful) + { + failedDeferredConfigIds.Add(env.ConfigurationId); + this.Logger.LogWarning("Failed to download deferred enterprise configuration '{ConfigId}' during startup. Keeping managed plugins unchanged.", env.ConfigurationId); + } + } + + if (EnterpriseEnvironmentService.HasValidEnterpriseSnapshot) + { + var activeConfigIds = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS + .Select(env => env.ConfigurationId) + .ToHashSet(); + + PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(activeConfigIds); + if (failedDeferredConfigIds.Count > 0) + this.Logger.LogWarning("Deferred startup updates failed for {FailedCount} enterprise configuration(s). Those configurations were kept unchanged.", failedDeferredConfigIds.Count); + } // Initialize the enterprise encryption service for decrypting API keys: await PluginFactory.InitializeEnterpriseEncryption(this.RustService); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 5a964179..435a6a56 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -64,33 +64,19 @@ @foreach (var plug in this.configPlugins) { - -
- - @plug.Name -
-
- - @T("Configuration plugin ID:") @plug.Id - -
-
+ } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; @@ -101,97 +87,91 @@ @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) { - -
- - @T("Waiting for the configuration plugin...") -
-
- - @T("Enterprise configuration ID:") @env.ConfigurationId - -
-
- - @T("Configuration server:") @env.ConfigurationServerUrl - -
-
+ } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; case true: - - @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") - + @if (this.HasAnyLoadedEnterpriseConfigurationPlugin) + { + + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") + + } + else + { + + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.") + + } @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) { - var matchingPlugin = this.configPlugins.FirstOrDefault(p => p.Id == env.ConfigurationId); - -
- @if (matchingPlugin is not null) - { - - @matchingPlugin.Name - } - else - { - - @T("ID mismatch: the plugin ID differs from the enterprise configuration ID.") - } -
-
- - @T("Enterprise configuration ID:") @env.ConfigurationId - -
-
- - @T("Configuration server:") @env.ConfigurationServerUrl - -
- @if (matchingPlugin is not null) - { -
- - @T("Configuration plugin ID:") @matchingPlugin.Id - -
- } -
+ var matchingPlugin = this.FindManagedConfigurationPlugin(env.ConfigurationId); + if (matchingPlugin is null) + { + + continue; + } + + } - -
- - @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) - { - - @T("Encryption secret: is configured") - } - else - { - - @T("Encryption secret: is not configured") - } -
-
+ +
break; } diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index a4eb5123..2027285f 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -69,13 +69,20 @@ public partial class Information : MSGComponentBase private bool showDatabaseDetails; - private List configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); + private List configPlugins = PluginFactory.AvailablePlugins + .Where(x => x.Type is PluginType.CONFIGURATION) + .OfType() + .ToList(); private sealed record DatabaseDisplayInfo(string Label, string Value); private readonly List databaseDisplayInfo = new(); private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive); + + private bool HasAnyLoadedEnterpriseConfigurationPlugin => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS + .Where(e => e.IsActive) + .Any(env => this.FindManagedConfigurationPlugin(env.ConfigurationId) is not null); /// /// Determines whether the enterprise configuration has details that can be shown/hidden. @@ -130,7 +137,10 @@ public partial class Information : MSGComponentBase switch (triggeredEvent) { case Event.PLUGINS_RELOADED: - this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); + this.configPlugins = PluginFactory.AvailablePlugins + .Where(x => x.Type is PluginType.CONFIGURATION) + .OfType() + .ToList(); await this.InvokeAsync(this.StateHasChanged); break; } @@ -196,6 +206,18 @@ public partial class Information : MSGComponentBase this.showDatabaseDetails = !this.showDatabaseDetails; } + private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId) + { + return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId) + // Backward compatibility for already downloaded plugins without ManagedConfigurationId. + ?? this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId is null && plugin.Id == configurationId); + } + + private bool IsManagedConfigurationIdMismatch(IAvailablePlugin plugin, Guid configurationId) + { + return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId; + } + private async Task CopyStartupLogPath() { await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath); diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 4c37375a..6fa2a8c9 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -24,6 +24,9 @@ VERSION = "1.0.0" -- The type of the plugin: TYPE = "CONFIGURATION" +-- True when this plugin is deployed by an enterprise configuration server: +DEPLOYED_USING_CONFIG_SERVER = false + -- The authors of the plugin: AUTHORS = {""} diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index 59909b25..d5a6f20a 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -56,43 +56,43 @@ public sealed record EmbeddingProvider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } @@ -114,7 +114,7 @@ public sealed record EmbeddingProvider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -128,31 +128,31 @@ public sealed record EmbeddingProvider( name, decryptedApiKey, SecretStoreType.EMBEDDING_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 2990655a..a2a0a0d3 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -94,31 +94,31 @@ public sealed record Provider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("InstanceName", out var instanceNameValue) || !instanceNameValue.TryRead(out var instanceName)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } @@ -127,27 +127,27 @@ public sealed record Provider( { if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})"); hfInferenceProvider = HFInferenceProvider.NONE; } } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead(out var additionalJsonApiParameters)) { // In this case, no reason exists to reject this provider, though. - LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters."); + LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters. (Plugin ID: {configPluginId})"); additionalJsonApiParameters = string.Empty; } @@ -171,7 +171,7 @@ public sealed record Provider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -185,31 +185,31 @@ public sealed record Provider( instanceName, decryptedApiKey, SecretStoreType.LLM_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index c4acf865..4c6ca871 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -56,43 +56,43 @@ public sealed record TranscriptionProvider( provider = NONE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead(out var usedLLMProviderText) || !Enum.TryParse(usedLLMProviderText, true, out var usedLLMProvider)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead(out var hostText) || !Enum.TryParse(hostText, true, out var host)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})"); return false; } - if (!TryReadModelTable(idx, modelTable, out var model)) + if (!TryReadModelTable(idx, modelTable, configPluginId, out var model)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})"); return false; } @@ -114,7 +114,7 @@ public sealed record TranscriptionProvider( if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) { if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) - LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); else { var encryption = PluginFactory.EnterpriseEncryption; @@ -128,31 +128,31 @@ public sealed record TranscriptionProvider( name, decryptedApiKey, SecretStoreType.TRANSCRIPTION_PROVIDER)); - LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring."); + LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect."); + LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); } else - LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured."); + LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})"); } } return true; } - private static bool TryReadModelTable(int idx, LuaTable table, out Model model) + private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model) { model = default; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var id)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})"); return false; } if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead(out var displayName)) { - LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name."); + LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})"); return false; } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs b/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs index a992d303..d1221c0a 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/IAvailablePlugin.cs @@ -3,4 +3,8 @@ namespace AIStudio.Tools.PluginSystem; public interface IAvailablePlugin : IPluginMetadata { public string LocalPath { get; } + + public bool IsManagedByConfigServer { get; } + + public Guid? ManagedConfigurationId { get; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index a8e10d5d..d28064e0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -17,6 +17,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT /// The list of configuration objects. Configuration objects are, e.g., providers or chat templates. /// public IEnumerable ConfigObjects => this.configObjects; + + /// + /// True/false when explicitly configured in the plugin, otherwise null. + /// + public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state); public async Task InitializeAsync(bool dryRun) { @@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT /// private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId; + private static bool? ReadDeployedUsingConfigServer(LuaState state) + { + if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead(out var deployedUsingConfigServer)) + return deployedUsingConfigServer; + + return null; + } + /// /// Tries to initialize the UI text content of the plugin. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index ffc6f5c0..d0b299d3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -79,13 +79,13 @@ public sealed record PluginConfigurationObject if (luaTableName is null) { - LOG.LogError($"The configuration object type '{configObjectType}' is not supported yet."); + LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId); return false; } if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead(out var luaTable)) { - LOG.LogWarning($"The {luaTableName} table does not exist or is not a valid table."); + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId); return false; } @@ -97,7 +97,7 @@ public sealed record PluginConfigurationObject var luaObjectTableValue = luaTable[i]; if (!luaObjectTableValue.TryRead(out var luaObjectTable)) { - LOG.LogWarning($"The {luaObjectTable} table at index {i} is not a valid table."); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId); continue; } @@ -151,12 +151,12 @@ public sealed record PluginConfigurationObject random ??= new ThreadSafeRandom(); configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; storedObjects.Add((TClass)configObject); - LOG.LogWarning($"The next number for the configuration object '{configObject.Name}' (id={configObject.Id}) could not be incremented. Using a random number instead."); + LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); } } } else - LOG.LogWarning($"The {luaObjectTable} table at index {i} does not contain a valid chat template configuration."); + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid configuration object (type={ConfigObjectType}, config plugin id: {ConfigPluginId}).", luaTableName, i, configObjectType, configPluginId); } return true; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index e3923b65..9b56e3af 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { - public static async Task DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) + public static async Task<(bool Success, EntityTagHeaderValue? ETag, string? Issue)> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) { if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl)) - return null; + return (false, null, "Configuration ID or server URL is missing."); try { @@ -18,18 +18,24 @@ public static partial class PluginFactory using var http = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - return response.Headers.ETag; + if (!response.IsSuccessStatusCode) + { + LOG.LogError($"Failed to determine the ETag for configuration plugin '{configPlugId}'. HTTP Status: {response.StatusCode}"); + return (false, null, $"HTTP status: {response.StatusCode}"); + } + + return (true, response.Headers.ETag, null); } catch (Exception e) { LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin."); - return null; + return (false, null, e.Message); } } public static async Task TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default) { - if(!IS_INITIALIZED) + if(!IsInitialized) { LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin."); return false; @@ -40,36 +46,72 @@ public static partial class PluginFactory LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})"); var tempDownloadFile = Path.GetTempFileName(); + var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}"); + string? backupDirectory = null; + var wasSuccessful = false; try { await LockHotReloadAsync(); using var httpClient = new HttpClient(); var response = await httpClient.GetAsync(downloadUrl, cancellationToken); - if (response.IsSuccessStatusCode) + if (!response.IsSuccessStatusCode) { - await using(var tempFileStream = File.Create(tempDownloadFile)) - { - await response.Content.CopyToAsync(tempFileStream, cancellationToken); - } - - var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); - if(Directory.Exists(configDirectory)) - Directory.Delete(configDirectory, true); - - Directory.CreateDirectory(configDirectory); - ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory); - - LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'."); - } - else LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}"); + return false; + } + + await using(var tempFileStream = File.Create(tempDownloadFile)) + { + await response.Content.CopyToAsync(tempFileStream, cancellationToken); + } + + ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory); + + var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); + if (Directory.Exists(configDirectory)) + { + backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}"); + Directory.Move(configDirectory, backupDirectory); + } + + Directory.Move(stagedDirectory, configDirectory); + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory)) + Directory.Delete(backupDirectory, true); + + LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'."); + wasSuccessful = true; } catch (Exception e) { LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin."); + + var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory)) + { + try + { + Directory.Move(backupDirectory, configDirectory); + } + catch (Exception restoreException) + { + LOG.LogError(restoreException, "Failed to restore the previous configuration plugin after a failed update."); + } + } } finally { + if (Directory.Exists(stagedDirectory)) + { + try + { + Directory.Delete(stagedDirectory, true); + } + catch (Exception e) + { + LOG.LogError(e, "Failed to delete the staged configuration plugin directory."); + } + } + if (File.Exists(tempDownloadFile)) { try @@ -85,6 +127,6 @@ public static partial class PluginFactory UnlockHotReload(); } - return true; + return wasSuccessful; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs index b98fa3c7..0505787c 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.HotReload.cs @@ -6,7 +6,7 @@ public static partial class PluginFactory public static void SetUpHotReloading() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs index 42165bfb..b2cbe515 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs @@ -10,7 +10,7 @@ public static partial class PluginFactory { public static async Task EnsureInternalPlugins() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 9565a833..9fa39bde 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -30,7 +30,7 @@ public static partial class PluginFactory /// public static async Task LoadAll(CancellationToken cancellationToken = default) { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it."); return; @@ -104,16 +104,40 @@ public static partial class PluginFactory LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')"); - // For configuration plugins, validate that the plugin ID matches the enterprise config ID - // (the directory name under which the plugin was downloaded): - if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase)) + var isConfigurationPluginInConfigDirectory = + plugin.Type is PluginType.CONFIGURATION && + pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase); + + var isManagedByConfigServer = false; + Guid? managedConfigurationId = null; + if (plugin is PluginConfiguration configPlugin) { - var directoryName = Path.GetFileName(pluginPath); - if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id) - LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID."); + if (configPlugin.DeployedUsingConfigServer.HasValue) + isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value; + + else if (isConfigurationPluginInConfigDirectory) + { + isManagedByConfigServer = true; + LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'."); + } } - AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath)); + // For configuration plugins, validate that the plugin ID matches the enterprise config ID + // (the directory name under which the plugin was downloaded): + if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer) + { + var directoryName = Path.GetFileName(pluginPath); + if (Guid.TryParse(directoryName, out var enterpriseConfigId)) + { + managedConfigurationId = enterpriseConfigId; + if (enterpriseConfigId != plugin.Id) + LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID."); + } + else + LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID."); + } + + AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId)); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs index 9fa82a66..0a7b4a12 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs @@ -1,54 +1,129 @@ +using System.Text.RegularExpressions; + namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { - public static void RemovePluginAsync(Guid pluginId) + private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments"; + + public static void RemoveUnreferencedManagedConfigurationPlugins(ISet activeConfigurationIds) { - if (!IS_INITIALIZED) + if (!IsInitialized) return; - LOG.LogWarning($"Try to remove plugin with ID: {pluginId}"); + var pluginIdsToRemove = new HashSet(); + + // Case 1: Plugins are already loaded and metadata is available. + foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => + plugin.Type is PluginType.CONFIGURATION && + plugin.IsManagedByConfigServer && + !activeConfigurationIds.Contains(plugin.Id))) + pluginIdsToRemove.Add(plugin.Id); + + // Case 2: Startup cleanup before the initial plugin load. + // In this case, we inspect the .config directories directly. + if (Directory.Exists(CONFIGURATION_PLUGINS_ROOT)) + { + foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT)) + { + var directoryName = Path.GetFileName(pluginDirectory); + if (!Guid.TryParse(directoryName, out var pluginId)) + continue; + + if (activeConfigurationIds.Contains(pluginId)) + continue; + + var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory); + var isManagedByConfigServer = deployFlag ?? true; + if (!deployFlag.HasValue) + LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'."); + + if (isManagedByConfigServer) + pluginIdsToRemove.Add(pluginId); + } + } + + foreach (var pluginId in pluginIdsToRemove) + RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED); + } + + private static void RemovePluginAsync(Guid pluginId, string reason) + { + if (!IsInitialized) + return; + + LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason); // // Remove the plugin from the available plugins list: // var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId); - if (availablePluginToRemove == null) - { - LOG.LogWarning($"No plugin found with ID: {pluginId}"); - return; - } - - AVAILABLE_PLUGINS.Remove(availablePluginToRemove); + if (availablePluginToRemove != null) + AVAILABLE_PLUGINS.Remove(availablePluginToRemove); + else + LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason); // // Remove the plugin from the running plugins list: // var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId); if (runningPluginToRemove == null) - LOG.LogWarning($"No running plugin found with ID: {pluginId}"); + LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason); else RUNNING_PLUGINS.Remove(runningPluginToRemove); // // Delete the plugin directory: // - var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString()); - if (Directory.Exists(pluginDirectory)) - { - try - { - Directory.Delete(pluginDirectory, true); - LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully."); - } - catch (Exception ex) - { - LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'."); - } - } - else - LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist."); + DeleteConfigurationPluginDirectory(pluginId); - LOG.LogInformation($"Plugin with ID: {pluginId} removed successfully."); + LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason); } + + private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory) + { + try + { + var pluginFile = Path.Join(pluginDirectory, "plugin.lua"); + if (!File.Exists(pluginFile)) + return null; + + var pluginCode = File.ReadAllText(pluginFile); + var match = DeployedByConfigServerRegex().Match(pluginCode); + if (!match.Success) + return null; + + return bool.TryParse(match.Groups[1].Value, out var deployFlag) + ? deployFlag + : null; + } + catch (Exception ex) + { + LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'."); + return null; + } + } + + private static void DeleteConfigurationPluginDirectory(Guid pluginId) + { + var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString()); + if (!Directory.Exists(pluginDirectory)) + { + LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist."); + return; + } + + try + { + Directory.Delete(pluginDirectory, true); + LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully."); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'."); + } + } + + [GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex DeployedByConfigServerRegex(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs index 5d734b06..861dfce6 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs @@ -34,7 +34,7 @@ public static partial class PluginFactory if (startedBasePlugin is PluginLanguage languagePlugin) { - BASE_LANGUAGE_PLUGIN = languagePlugin; + BaseLanguage = languagePlugin; RUNNING_PLUGINS.Add(languagePlugin); LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'"); } @@ -44,7 +44,7 @@ public static partial class PluginFactory catch (Exception e) { LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'."); - BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE; + BaseLanguage = NoPluginLanguage.INSTANCE; } } @@ -106,8 +106,8 @@ public static partial class PluginFactory // // When this is a language plugin, we need to set the base language plugin. // - if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE) - languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN); + if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE) + languagePlugin.SetBaseLanguage(BaseLanguage); if(plugin is PluginConfiguration configPlugin) await configPlugin.InitializeAsync(false); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index 2c20ede0..5f7f0df0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -6,17 +6,17 @@ public static partial class PluginFactory { private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory)); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); - - private static bool IS_INITIALIZED; + private static string DATA_DIR = string.Empty; private static string PLUGINS_ROOT = string.Empty; private static string INTERNAL_PLUGINS_ROOT = string.Empty; private static string CONFIGURATION_PLUGINS_ROOT = string.Empty; private static string HOT_RELOAD_LOCK_FILE = string.Empty; private static FileSystemWatcher HOT_RELOAD_WATCHER = null!; - private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE; - public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN; + public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE; + + public static bool IsInitialized { get; private set; } /// /// Gets the enterprise encryption instance for decrypting API keys in configuration plugins. @@ -47,7 +47,7 @@ public static partial class PluginFactory /// public static bool Setup() { - if(IS_INITIALIZED) + if(IsInitialized) return false; LOG.LogInformation("Initializing plugin factory..."); @@ -61,14 +61,14 @@ public static partial class PluginFactory Directory.CreateDirectory(PLUGINS_ROOT); HOT_RELOAD_WATCHER = new(PLUGINS_ROOT); - IS_INITIALIZED = true; + IsInitialized = true; LOG.LogInformation("Plugin factory initialized successfully."); return true; } private static async Task LockHotReloadAsync() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized."); return; @@ -92,7 +92,7 @@ public static partial class PluginFactory private static void UnlockHotReload() { - if (!IS_INITIALIZED) + if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized."); return; @@ -113,7 +113,7 @@ public static partial class PluginFactory public static void Dispose() { - if(!IS_INITIALIZED) + if(!IsInitialized) return; HOT_RELOAD_WATCHER.Dispose(); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs index e98644cb..db07035a 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginMetadata.cs @@ -1,6 +1,6 @@ namespace AIStudio.Tools.PluginSystem; -public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin +public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin { #region Implementation of IPluginMetadata @@ -51,6 +51,10 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail #region Implementation of IAvailablePlugin public string LocalPath { get; } = localPath; + + public bool IsManagedByConfigServer { get; } = isManagedByConfigServer; + + public Guid? ManagedConfigurationId { get; } = managedConfigurationId; #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index ec0ee648..0d2f2aa1 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -5,6 +5,8 @@ namespace AIStudio.Tools.Services; public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService { public static List CURRENT_ENVIRONMENTS = []; + + public static bool HasValidEnterpriseSnapshot { get; private set; } #if DEBUG private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); @@ -33,34 +35,10 @@ public sealed class EnterpriseEnvironmentService(ILogger deleteConfigIds; - try - { - deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds(); - } - catch (Exception e) - { - logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed"); - return; - } - - foreach (var deleteId in deleteConfigIds) - { - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId); - if (isPluginInUse) - { - logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId); - PluginFactory.RemovePluginAsync(deleteId); - } - } - - // - // Step 2: Fetch all active configurations. + // Step 1: Fetch all active configurations. // List fetchedConfigs; try @@ -75,9 +53,20 @@ public sealed class EnterpriseEnvironmentService(ILogger(); + var reachableEnvironments = new List(); + var failedConfigIds = new HashSet(); + var currentEnvironmentsById = CURRENT_ENVIRONMENTS + .GroupBy(env => env.ConfigurationId) + .ToDictionary(group => group.Key, group => group.Last()); + + var activeFetchedEnvironmentsById = fetchedConfigs + .Where(config => config.IsActive) + .GroupBy(config => config.ConfigurationId) + .ToDictionary(group => group.Key, group => group.Last()); + foreach (var config in fetchedConfigs) { if (!config.IsActive) @@ -86,72 +75,98 @@ public sealed class EnterpriseEnvironmentService(ILogger 0) + var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl); + if (!etagResponse.Success) { - logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs."); - - // Remove plugins for configs that were previously active: - foreach (var oldEnv in CURRENT_ENVIRONMENTS) - { - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); - if (isPluginInUse) - PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); - } - } - else - logger.LogInformation("AI Studio runs without any enterprise configurations."); - - CURRENT_ENVIRONMENTS = []; - return; - } - - // - // Step 4: Compare with current environments and process changes. - // - var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet(); - var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet(); - - // Remove plugins for configs that are no longer present: - foreach (var oldEnv in CURRENT_ENVIRONMENTS) - { - if (!nextIds.Contains(oldEnv.ConfigurationId)) - { - logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId); - var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); - if (isPluginInUse) - PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); - } - } - - // Process new or changed configs: - foreach (var nextEnv in nextEnvironments) - { - var currentEnv = CURRENT_ENVIRONMENTS.FirstOrDefault(e => e.ConfigurationId == nextEnv.ConfigurationId); - if (currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag). - { - logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId); + failedConfigIds.Add(config.ConfigurationId); + logger.LogWarning("Failed to read enterprise config metadata for '{ConfigId}' from '{ServerUrl}': {Issue}. Keeping the current plugin state for this configuration.", config.ConfigurationId, config.ConfigurationServerUrl, etagResponse.Issue ?? "Unknown issue"); continue; } - var isNew = !currentIds.Contains(nextEnv.ConfigurationId); - if(isNew) + reachableEnvironments.Add(config with { ETag = etagResponse.ETag }); + } + + // + // Step 3: Compare with current environments and process changes. + // Download per configuration. A single failure must not block others. + // + var shouldDeferStartupDownloads = isFirstRun && !PluginFactory.IsInitialized; + var effectiveEnvironmentsById = new Dictionary(); + + // Process new or changed configs: + foreach (var nextEnv in reachableEnvironments) + { + var hasCurrentEnvironment = currentEnvironmentsById.TryGetValue(nextEnv.ConfigurationId, out var currentEnv); + if (hasCurrentEnvironment && currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag). + { + logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + continue; + } + + if(!hasCurrentEnvironment) logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); else logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId); - if (isFirstRun) + if (shouldDeferStartupDownloads) + { MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + } else - await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + { + var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + if (!wasDownloadSuccessful) + { + failedConfigIds.Add(nextEnv.ConfigurationId); + if (hasCurrentEnvironment) + { + logger.LogWarning("Failed to update enterprise configuration '{ConfigId}'. Keeping the previously active version.", nextEnv.ConfigurationId); + effectiveEnvironmentsById[nextEnv.ConfigurationId] = currentEnv; + } + else + logger.LogWarning("Failed to download the new enterprise configuration '{ConfigId}'. Skipping activation for now.", nextEnv.ConfigurationId); + + continue; + } + + effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv; + } } - CURRENT_ENVIRONMENTS = nextEnvironments; + // Retain configurations for all failed IDs. On cold start there might be no + // previous in-memory snapshot yet, so we also keep the current fetched entry + // to protect it from cleanup while the server is unreachable. + foreach (var failedConfigId in failedConfigIds) + { + if (effectiveEnvironmentsById.ContainsKey(failedConfigId)) + continue; + + if (!currentEnvironmentsById.TryGetValue(failedConfigId, out var retainedEnvironment)) + { + if (!activeFetchedEnvironmentsById.TryGetValue(failedConfigId, out retainedEnvironment)) + continue; + + logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Protecting it from cleanup until connectivity is restored.", failedConfigId); + } + else + logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Keeping the previously active version.", failedConfigId); + + effectiveEnvironmentsById[failedConfigId] = retainedEnvironment; + } + + var effectiveEnvironments = effectiveEnvironmentsById.Values.ToList(); + + // Cleanup is only allowed after a successful sync cycle: + if (PluginFactory.IsInitialized && !shouldDeferStartupDownloads) + PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(effectiveEnvironmentsById.Keys.ToHashSet()); + + if (effectiveEnvironments.Count == 0) + logger.LogInformation("AI Studio runs without any enterprise configurations."); + + CURRENT_ENVIRONMENTS = effectiveEnvironments; + HasValidEnterpriseSnapshot = true; } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index cf8fbc26..d78567f4 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -36,13 +36,12 @@ public sealed partial class RustService var result = await this.http.GetAsync("/system/enterprise/configs"); if (!result.IsSuccessStatusCode) { - this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'"); - return []; + throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'"); } var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); if (configs is null) - return []; + throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust."); var environments = new List(); foreach (var config in configs) @@ -55,35 +54,4 @@ public sealed partial class RustService return environments; } - - /// - /// Reads all enterprise configuration IDs that should be deleted. - /// - /// - /// Returns a list of GUIDs representing configuration IDs to remove. - /// - public async Task> EnterpriseEnvDeleteConfigIds() - { - var result = await this.http.GetAsync("/system/enterprise/delete-configs"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'"); - return []; - } - - var ids = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); - if (ids is null) - return []; - - var guids = new List(); - foreach (var idStr in ids) - { - if (Guid.TryParse(idStr, out var id)) - guids.Add(id); - else - this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'."); - } - - return guids; - } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 1cab7ec4..bb4d39ba 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -4,11 +4,14 @@ - Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. - Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details. +- Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details. +- Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example, during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. - Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. +- Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. - Upgraded dependencies. \ No newline at end of file diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index dd62dd77..279214d2 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -25,8 +25,6 @@ AI Studio supports loading multiple enterprise configurations simultaneously. Th - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_ids` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS`: One or more configuration IDs that should be removed, separated by `;`. The format is: `id1;id2;id3`. This is helpful if an employee moves to a different department or leaves the organization. - - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret. **Example:** To configure two enterprise configurations (one for the organization and one for a department): @@ -37,14 +35,100 @@ MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https **Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority. +### Windows GPO / PowerShell example for `configs` + +If you distribute multiple GPOs, each GPO should read and write the same registry value (`configs`) and only update its own `id@url` entry. Other entries must stay untouched. + +The following PowerShell example provides helper functions for appending and removing entries safely: + +```powershell +$RegistryPath = "HKCU:\Software\github\MindWork AI Studio\Enterprise IT" +$ConfigsValueName = "configs" + +function Get-ConfigEntries { + param([string]$RawValue) + + if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() } + + $entries = @() + foreach ($part in $RawValue.Split(';')) { + $trimmed = $part.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } + + $pair = $trimmed.Split('@', 2) + if ($pair.Count -ne 2) { continue } + + $id = $pair[0].Trim().ToLowerInvariant() + $url = $pair[1].Trim() + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($url)) { continue } + + $entries += [PSCustomObject]@{ + Id = $id + Url = $url + } + } + + return $entries +} + +function ConvertTo-ConfigValue { + param([array]$Entries) + + return ($Entries | ForEach-Object { "$($_.Id)@$($_.Url)" }) -join ';' +} + +function Add-EnterpriseConfigEntry { + param( + [Parameter(Mandatory=$true)][Guid]$ConfigId, + [Parameter(Mandatory=$true)][string]$ServerUrl + ) + + if (-not (Test-Path $RegistryPath)) { + New-Item -Path $RegistryPath -Force | Out-Null + } + + $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName + $entries = Get-ConfigEntries -RawValue $raw + $normalizedId = $ConfigId.ToString().ToLowerInvariant() + $normalizedUrl = $ServerUrl.Trim() + + # Replace only this one ID, keep all other entries unchanged. + $entries = @($entries | Where-Object { $_.Id -ne $normalizedId }) + $entries += [PSCustomObject]@{ + Id = $normalizedId + Url = $normalizedUrl + } + + Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $entries) +} + +function Remove-EnterpriseConfigEntry { + param( + [Parameter(Mandatory=$true)][Guid]$ConfigId + ) + + if (-not (Test-Path $RegistryPath)) { return } + + $raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName + $entries = Get-ConfigEntries -RawValue $raw + $normalizedId = $ConfigId.ToString().ToLowerInvariant() + + # Remove only this one ID, keep all other entries unchanged. + $updated = @($entries | Where-Object { $_.Id -ne $normalizedId }) + Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $updated) +} + +# Example usage: +# Add-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" -ServerUrl "https://intranet.example.org:30100/ai-studio/configuration" +# Remove-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" +``` + ### Single configuration (legacy) The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations: - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person. -- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID`: This is a configuration ID that should be removed. This is helpful if an employee moves to a different department or leaves the organization. - - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file. - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. @@ -107,6 +191,16 @@ For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861 ID = "9072b77d-ca81-40da-be6a-861da525ef7b" ``` +## Important: Mark enterprise-managed plugins explicitly + +Configuration plugins deployed by your configuration server should define: + +```lua +DEPLOYED_USING_CONFIG_SERVER = true +``` + +Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning. + ## Example AI Studio configuration The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files: @@ -173,4 +267,4 @@ CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { } ``` -The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). \ No newline at end of file +The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 478fbff4..c5f0a6c7 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,7 +1,7 @@ use std::env; use std::sync::OnceLock; use log::{debug, info, warn}; -use rocket::{delete, get}; +use rocket::get; use rocket::serde::json::Json; use serde::Serialize; use sys_locale::get_locale; @@ -178,30 +178,6 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String { ) } -#[delete("/system/enterprise/config/id")] -pub fn delete_enterprise_env_config_id(_token: APIToken) -> String { - // - // When we are on a Windows machine, we try to read the enterprise config from - // the Windows registry. In case we can't find the registry key, or we are on a - // macOS or Linux machine, we try to read the enterprise config from the - // environment variables. - // - // The registry key is: - // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT - // - // In this registry key, we expect the following values: - // - delete_config_id - // - // The environment variable is: - // MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID - // - debug!("Trying to read the enterprise environment for some config ID, which should be deleted."); - get_enterprise_configuration( - "delete_config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", - ) -} - #[get("/system/enterprise/config/server")] pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { // @@ -314,46 +290,6 @@ pub fn read_enterprise_configs(_token: APIToken) -> Json> Json(configs) } -/// Returns all enterprise configuration IDs that should be deleted. Supports the new -/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable. -#[get("/system/enterprise/delete-configs")] -pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json> { - info!("Trying to read the enterprise environment for configuration IDs to delete."); - - let mut ids: Vec = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - - // Read the new combined format: - let combined = get_enterprise_configuration( - "delete_config_ids", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS", - ); - - if !combined.is_empty() { - for id in combined.split(';') { - let id = id.trim().to_lowercase(); - if !id.is_empty() && seen.insert(id.clone()) { - ids.push(id); - } - } - } - - // Also read the legacy single-delete variable: - let delete_id = get_enterprise_configuration( - "delete_config_id", - "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", - ); - - if !delete_id.is_empty() { - let id = delete_id.trim().to_lowercase(); - if seen.insert(id.clone()) { - ids.push(id); - } - } - - Json(ids) -} - fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 3a4c1f9c..64bc8174 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -83,11 +83,9 @@ pub fn start_runtime_api() { crate::environment::get_config_directory, crate::environment::read_user_language, crate::environment::read_enterprise_env_config_id, - crate::environment::delete_enterprise_env_config_id, crate::environment::read_enterprise_env_config_server_url, crate::environment::read_enterprise_env_config_encryption_secret, crate::environment::read_enterprise_configs, - crate::environment::read_enterprise_delete_config_ids, crate::file_data::extract_data, crate::log::get_log_paths, crate::log::log_event, From 21780ad481fbb52040de8002d9446fe99967c1e0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 19 Feb 2026 20:57:56 +0100 Subject: [PATCH 02/14] Upgraded to .NET 9.0.13 & Rust 1.93.1 (#669) --- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + metadata.txt | 6 +++--- 2 files changed, 4 insertions(+), 3 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 bb4d39ba..37b23984 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -14,4 +14,5 @@ - Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. +- Upgraded to .NET 9.0.13 & Rust 1.93.1. - Upgraded dependencies. \ No newline at end of file diff --git a/metadata.txt b/metadata.txt index fe984c68..0fe67ec5 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,9 +1,9 @@ 26.2.1 2026-02-01 19:16:01 UTC 233 -9.0.113 (commit 64f9f590b3) -9.0.12 (commit 2f12400757) -1.93.0 (commit 254b59607) +9.0.114 (commit 4c5aac3d56) +9.0.13 (commit 9ecbfd4f3f) +1.93.1 (commit 01f6ddf75) 8.15.0 1.8.1 8f9cd40d060, release From 5e603f9f4cfda3da5047205da2ee61f65fd55152 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 19 Feb 2026 21:24:58 +0100 Subject: [PATCH 03/14] Localized profile name usage (#670) --- .../Components/ProfileFormSelection.razor | 2 +- .../Settings/ConfigurationSelectDataFactory.cs | 2 +- app/MindWork AI Studio/packages.lock.json | 6 +++--- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/MindWork AI Studio/Components/ProfileFormSelection.razor b/app/MindWork AI Studio/Components/ProfileFormSelection.razor index cd063d41..adeac59b 100644 --- a/app/MindWork AI Studio/Components/ProfileFormSelection.razor +++ b/app/MindWork AI Studio/Components/ProfileFormSelection.razor @@ -6,7 +6,7 @@ @foreach (var profile in this.SettingsManager.ConfigurationData.Profiles.GetAllProfiles()) { - @profile.Name + @profile.GetSafeName() } diff --git a/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs b/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs index 3aa9342b..7aa2441e 100644 --- a/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs +++ b/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs @@ -201,7 +201,7 @@ public static class ConfigurationSelectDataFactory public static IEnumerable> GetProfilesData(IEnumerable profiles) { foreach (var profile in profiles.GetAllProfiles()) - yield return new(profile.Name, profile.Id); + yield return new(profile.GetSafeName(), profile.Id); } public static IEnumerable> GetTranscriptionProvidersData(IEnumerable transcriptionProviders) diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index 7dff471e..ee106ea9 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -37,9 +37,9 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[9.0.12, )", - "resolved": "9.0.12", - "contentHash": "StA3kyImQHqDo8A8ZHaSxgASbEuT5UIqgeCvK5SzUPj//xE1QSys421J9pEs4cYuIVwq7CJvWSKxtyH7aPr1LA==" + "requested": "[9.0.13, )", + "resolved": "9.0.13", + "contentHash": "f7t15I9ZXV7fNk3FIzPAlkJNG1A1tkSeDpRh+TFWEToGGqA+uj6uqU15I8YOkkYICNY2tqOVm2CMe6ScPFPwEg==" }, "MudBlazor": { "type": "Direct", 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 37b23984..9ca3477a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -13,6 +13,7 @@ - Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. - Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. +- Fixed an issue where in some places "No profile" was displayed instead of the localized text. - 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 to .NET 9.0.13 & Rust 1.93.1. - Upgraded dependencies. \ No newline at end of file From 1aa27d217a6e59b191b149e8c998ff6faed2fdac Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 19 Feb 2026 21:31:59 +0100 Subject: [PATCH 04/14] Restrict export actions to admin settings visibility (#671) --- .../Components/Settings/SettingsPanelEmbeddings.razor | 9 ++++++--- .../Components/Settings/SettingsPanelEmbeddings.razor.cs | 3 +++ .../Components/Settings/SettingsPanelProviders.razor | 9 ++++++--- .../Components/Settings/SettingsPanelProviders.razor.cs | 3 +++ .../Components/Settings/SettingsPanelTranscription.razor | 9 ++++++--- .../Settings/SettingsPanelTranscription.razor.cs | 3 +++ 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index addc4088..e68fdeee 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -53,9 +53,12 @@ - - - + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + + + } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 02b46c1a..0f78bb97 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -117,6 +117,9 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase private async Task ExportEmbeddingProvider(EmbeddingProvider provider) { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + if (provider == EmbeddingProvider.NONE) return; diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index 21cc511d..f6704dc5 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -45,9 +45,12 @@ - - - + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + + + } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 3388372a..500a4c2d 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -136,6 +136,9 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase private async Task ExportLLMProvider(AIStudio.Settings.Provider provider) { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + if (provider == AIStudio.Settings.Provider.NONE) return; diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 43da4dc6..7b417e58 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -50,9 +50,12 @@ - - - + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + + + } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs index fadd002a..e143ba82 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs @@ -117,6 +117,9 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase private async Task ExportTranscriptionProvider(TranscriptionProvider provider) { + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + return; + if (provider == TranscriptionProvider.NONE) return; From 6150d499972c300d93f88737f46736756a2f0118 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 20 Feb 2026 09:10:53 +0100 Subject: [PATCH 05/14] Fixed Google Gemini model API (#672) --- .../Provider/Google/Model.cs | 3 - .../Provider/Google/ModelsResponse.cs | 7 -- .../Provider/Google/ProviderGoogle.cs | 98 +++++++++++++------ .../wwwroot/changelog/v26.2.2.md | 1 + 4 files changed, 67 insertions(+), 42 deletions(-) delete mode 100644 app/MindWork AI Studio/Provider/Google/Model.cs delete mode 100644 app/MindWork AI Studio/Provider/Google/ModelsResponse.cs diff --git a/app/MindWork AI Studio/Provider/Google/Model.cs b/app/MindWork AI Studio/Provider/Google/Model.cs deleted file mode 100644 index f1a53282..00000000 --- a/app/MindWork AI Studio/Provider/Google/Model.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace AIStudio.Provider.Google; - -public readonly record struct Model(string Name, string DisplayName); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/ModelsResponse.cs b/app/MindWork AI Studio/Provider/Google/ModelsResponse.cs deleted file mode 100644 index 01cb81f9..00000000 --- a/app/MindWork AI Studio/Provider/Google/ModelsResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace AIStudio.Provider.Google; - -/// -/// A data model for the response from the model endpoint. -/// -/// -public readonly record struct ModelsResponse(IList Models); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 48dea49e..97157080 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -22,7 +22,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener public override string InstanceName { get; set; } = "Google Gemini"; /// - public override async IAsyncEnumerable StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) + public override async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default) { // Get the API key: var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.LLM_PROVIDER); @@ -76,57 +76,50 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override async IAsyncEnumerable StreamImageCompletion(Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public override async IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public override Task TranscribeAudioAsync(Provider.Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) + public override Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) { return Task.FromResult(string.Empty); } /// - public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var modelResponse = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); - if(modelResponse == default) - return []; - - return modelResponse.Models.Where(model => - model.Name.StartsWith("models/gemini-", StringComparison.OrdinalIgnoreCase) && !model.Name.Contains("embed")) - .Select(n => new Provider.Model(n.Name.Replace("models/", string.Empty), n.DisplayName)); + var models = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional); + return models.Where(model => + model.Id.StartsWith("gemini-", StringComparison.OrdinalIgnoreCase) && + !this.IsEmbeddingModel(model.Id)) + .Select(this.WithDisplayNameFallback); } /// - public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(Enumerable.Empty()); } - public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - var modelResponse = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); - if(modelResponse == default) - return []; - - return modelResponse.Models.Where(model => - model.Name.StartsWith("models/text-embedding-", StringComparison.OrdinalIgnoreCase) || - model.Name.StartsWith("models/gemini-embed", StringComparison.OrdinalIgnoreCase)) - .Select(n => new Provider.Model(n.Name.Replace("models/", string.Empty), n.DisplayName)); + var models = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional); + return models.Where(model => this.IsEmbeddingModel(model.Id)) + .Select(this.WithDisplayNameFallback); } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return Task.FromResult(Enumerable.Empty()); + return Task.FromResult(Enumerable.Empty()); } #endregion - private async Task LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) + private async Task> LoadModels(SecretStoreType storeType, CancellationToken token, string? apiKeyProvisional = null) { var secretKey = apiKeyProvisional switch { @@ -138,16 +131,57 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener } }; - if (secretKey is null) - return default; + if (string.IsNullOrWhiteSpace(secretKey)) + return []; - using var request = new HttpRequestMessage(HttpMethod.Get, $"models?key={secretKey}"); - using var response = await this.httpClient.SendAsync(request, token); + using var request = new HttpRequestMessage(HttpMethod.Get, "models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + using var response = await this.httpClient.SendAsync(request, token); if(!response.IsSuccessStatusCode) - return default; + { + LOGGER.LogError("Failed to load models with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, await response.Content.ReadAsStringAsync(token)); + return []; + } - var modelResponse = await response.Content.ReadFromJsonAsync(token); - return modelResponse; + try + { + var modelResponse = await response.Content.ReadFromJsonAsync(token); + if (modelResponse == default || modelResponse.Data.Count is 0) + { + LOGGER.LogError("Google model list response did not contain a valid data array."); + return []; + } + + return modelResponse.Data + .Where(model => !string.IsNullOrWhiteSpace(model.Id)) + .Select(model => new Model(this.NormalizeModelId(model.Id), model.DisplayName)) + .ToArray(); + } + catch (Exception e) + { + LOGGER.LogError("Failed to parse Google model list response: '{Message}'.", e.Message); + return []; + } + } + + private bool IsEmbeddingModel(string modelId) + { + return modelId.Contains("embedding", StringComparison.OrdinalIgnoreCase) || + modelId.Contains("embed", StringComparison.OrdinalIgnoreCase); + } + + private Model WithDisplayNameFallback(Model model) + { + return string.IsNullOrWhiteSpace(model.DisplayName) + ? new Model(model.Id, model.Id) + : model; + } + + private string NormalizeModelId(string modelId) + { + return modelId.StartsWith("models/", StringComparison.OrdinalIgnoreCase) + ? modelId["models/".Length..] + : modelId; } } \ 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 9ca3477a..b059b034 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -15,5 +15,6 @@ - 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 an issue where in some places "No profile" was displayed instead of the localized text. - 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. +- Fixed the Google Gemini model API. Switched to the default OpenAI-compatible API to retrieve the model list after Google changed the previous API, which stopped working. - Upgraded to .NET 9.0.13 & Rust 1.93.1. - Upgraded dependencies. \ No newline at end of file From ed8bd9d25c6e943a9bb2c7489f8354034fa84db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peer=20Sch=C3=BCtt?= Date: Fri, 20 Feb 2026 10:08:06 +0100 Subject: [PATCH 06/14] Added support for preselected provider in plugins (#668) --- .../Components/Settings/SettingsPanelApp.razor | 2 +- app/MindWork AI Studio/Plugins/configuration/plugin.lua | 5 +++++ app/MindWork AI Studio/Settings/DataModel/DataApp.cs | 2 +- .../Tools/PluginSystem/PluginConfiguration.cs | 3 +++ .../Tools/PluginSystem/PluginFactory.Loading.cs | 4 ++++ app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 6 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index a07fc65f..0b2e5c0e 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -29,7 +29,7 @@ } } - + @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 6fa2a8c9..5918b691 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -166,6 +166,11 @@ CONFIG["SETTINGS"] = {} -- Examples are PRE_WRITER_MODE_2024, PRE_RAG_2024, PRE_DOCUMENT_ANALYSIS_2025. -- CONFIG["SETTINGS"]["DataApp.EnabledPreviewFeatures"] = { "PRE_RAG_2024", "PRE_DOCUMENT_ANALYSIS_2025" } +-- Configure the preselected provider. +-- It must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. +-- Please note: using an empty string ("") will lock the preselected provider selection, even though no valid preselected provider is found. +-- CONFIG["SETTINGS"]["DataApp.PreselectedProvider"] = "00000000-0000-0000-0000-000000000000" + -- Configure the preselected profile. -- It must be one of the profile IDs defined in CONFIG["PROFILES"]. -- Please note: using an empty string ("") will lock the preselected profile selection, even though no valid preselected profile is found. diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 5671908f..a1def46f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -65,7 +65,7 @@ public sealed class DataApp(Expression>? configSelection = n /// /// Should we preselect a provider for the entire app? /// - public string PreselectedProvider { get; set; } = string.Empty; + public string PreselectedProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedProvider, string.Empty); /// /// Should we preselect a profile for the entire app? diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index d28064e0..15e845c1 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -148,6 +148,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Handle configured document analysis policies: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, x => x.NextDocumentAnalysisPolicyNum, mainTable, this.Id, ref this.configObjects, dryRun); + // Config: preselected provider? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); + // Config: preselected profile? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProfile, Guid.Empty, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 9fa39bde..40bc37c0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -187,6 +187,10 @@ public static partial class PluginFactory if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; + // Check for a preselected provider: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProvider, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + // Check for a preselected profile: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProfile, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; 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 b059b034..227c02fe 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 the option to set a predefined provider for the entire app via configuration plugins. - Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details. - Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details. - Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example, during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged. From 6f76c845f1b592da1b5fef04871828c2240be5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peer=20Sch=C3=BCtt?= Date: Fri, 20 Feb 2026 12:40:38 +0100 Subject: [PATCH 07/14] Introduced additive configuration handling for managed preview features (#667) Co-authored-by: Thorsten Sommer --- .../Components/ConfigurationMultiSelect.razor | 18 +++- .../ConfigurationMultiSelect.razor.cs | 14 +++ .../Settings/SettingsPanelApp.razor | 2 +- .../Settings/SettingsPanelApp.razor.cs | 36 ++++++- app/MindWork AI Studio/Settings/ConfigMeta.cs | 66 ++++++++++--- .../Settings/ManagedConfiguration.Parsing.cs | 94 ++++++++++++++++++- .../Settings/ManagedConfiguration.cs | 62 ++++++++---- .../Tools/PluginSystem/PluginConfiguration.cs | 4 +- .../PluginSystem/PluginFactory.Loading.cs | 3 + .../wwwroot/changelog/v26.2.2.md | 1 + 10 files changed, 260 insertions(+), 40 deletions(-) diff --git a/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor b/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor index 6d9d7b89..5ad7eb25 100644 --- a/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor +++ b/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor @@ -14,8 +14,22 @@ SelectedValuesChanged="@this.OptionChanged"> @foreach (var data in this.Data) { - - @data.Name + var isLockedValue = this.IsLockedValue(data.Value); + + @if (isLockedValue) + { + + @* MudTooltip.RootStyle is set as a workaround for issue -> https://github.com/MudBlazor/MudBlazor/issues/10882 *@ + + + + @data.Name + + } + else + { + @data.Name + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor.cs b/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor.cs index 1c5df8b8..e924b4fd 100644 --- a/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationMultiSelect.razor.cs @@ -27,6 +27,12 @@ public partial class ConfigurationMultiSelect : ConfigurationBaseCore /// [Parameter] public Action> SelectionUpdate { get; set; } = _ => { }; + + /// + /// Determines whether a specific item is locked by a configuration plugin. + /// + [Parameter] + public Func IsItemLocked { get; set; } = _ => false; #region Overrides of ConfigurationBase @@ -62,4 +68,12 @@ public partial class ConfigurationMultiSelect : ConfigurationBaseCore return string.Format(T("You have selected {0} preview features."), selectedValues.Count); } + + private bool IsLockedValue(TData value) => this.IsItemLocked(value); + + private string LockedTooltip() => + this.T( + "This feature is managed by your organization and has therefore been disabled.", + typeof(ConfigurationBase).Namespace, + nameof(ConfigurationBase)); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 0b2e5c0e..cbc33d79 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -25,7 +25,7 @@ var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList(); if (availablePreviewFeatures.Count > 0) { - + } } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs index 81c2b7e5..70b6d24a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs @@ -27,7 +27,41 @@ public partial class SettingsPanelApp : SettingsPanelBase private void UpdatePreviewFeatures(PreviewVisibility previewVisibility) { this.SettingsManager.ConfigurationData.App.PreviewVisibility = previewVisibility; - this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures = previewVisibility.FilterPreviewFeatures(this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures); + var filtered = previewVisibility.FilterPreviewFeatures(this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures); + filtered.UnionWith(this.GetPluginContributedPreviewFeatures()); + this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures = filtered; + } + + private HashSet GetPluginContributedPreviewFeatures() + { + if (ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.HasPluginContribution) + return meta.PluginContribution.Where(x => !x.IsReleased()).ToHashSet(); + + return []; + } + + private bool IsPluginContributedPreviewFeature(PreviewFeatures feature) + { + if (feature.IsReleased()) + return false; + + if (!ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) || !meta.HasPluginContribution) + return false; + + return meta.PluginContribution.Contains(feature); + } + + private HashSet GetSelectedPreviewFeatures() + { + var enabled = this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures.Where(x => !x.IsReleased()).ToHashSet(); + enabled.UnionWith(this.GetPluginContributedPreviewFeatures()); + return enabled; + } + + private void UpdateEnabledPreviewFeatures(HashSet selectedFeatures) + { + selectedFeatures.UnionWith(this.GetPluginContributedPreviewFeatures()); + this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures = selectedFeatures; } private async Task UpdateLangBehaviour(LangBehavior behavior) diff --git a/app/MindWork AI Studio/Settings/ConfigMeta.cs b/app/MindWork AI Studio/Settings/ConfigMeta.cs index f8d50ecc..6b81c3e8 100644 --- a/app/MindWork AI Studio/Settings/ConfigMeta.cs +++ b/app/MindWork AI Studio/Settings/ConfigMeta.cs @@ -28,14 +28,14 @@ public record ConfigMeta : ConfigMetaBase private Expression> PropertyExpression { get; } /// - /// Indicates whether the configuration is managed by a plugin and is therefore locked. + /// Indicates whether the configuration is locked by a configuration plugin. /// public bool IsLocked { get; private set; } /// - /// The ID of the plugin that manages this configuration. This is set when the configuration is locked. + /// The ID of the plugin that locked this configuration. /// - public Guid MangedByConfigPluginId { get; private set; } + public Guid LockedByConfigPluginId { get; private set; } /// /// The default value for the configuration property. This is used when resetting the property to its default state. @@ -43,30 +43,74 @@ public record ConfigMeta : ConfigMetaBase public required TValue Default { get; init; } /// - /// Locks the configuration state, indicating that it is managed by a specific plugin. + /// Indicates whether a plugin contribution is available. /// - /// The ID of the plugin that is managing this configuration. - public void LockManagedState(Guid pluginId) + public bool HasPluginContribution { get; private set; } + + /// + /// The additive value contribution provided by a configuration plugin. + /// + public TValue PluginContribution { get; private set; } = default!; + + /// + /// The ID of the plugin that provided the additive value contribution. + /// + public Guid PluginContributionByConfigPluginId { get; private set; } + + /// + /// Locks the configuration state, indicating that it is controlled by a specific plugin. + /// + /// The ID of the plugin that is locking this configuration. + public void LockConfiguration(Guid pluginId) { this.IsLocked = true; - this.MangedByConfigPluginId = pluginId; + this.LockedByConfigPluginId = pluginId; } /// - /// Resets the managed state of the configuration, allowing it to be modified again. + /// Resets the locked state of the configuration, allowing it to be modified again. /// This will also reset the property to its default value. /// - public void ResetManagedState() + public void ResetLockedConfiguration() { this.IsLocked = false; - this.MangedByConfigPluginId = Guid.Empty; + this.LockedByConfigPluginId = Guid.Empty; this.Reset(); } + + /// + /// Unlocks the configuration state without changing the current value. + /// + public void UnlockConfiguration() + { + this.IsLocked = false; + this.LockedByConfigPluginId = Guid.Empty; + } + + /// + /// Stores an additive plugin contribution. + /// + public void SetPluginContribution(TValue value, Guid pluginId) + { + this.PluginContribution = value; + this.PluginContributionByConfigPluginId = pluginId; + this.HasPluginContribution = true; + } + + /// + /// Clears the additive plugin contribution without changing the current value. + /// + public void ClearPluginContribution() + { + this.PluginContribution = default!; + this.PluginContributionByConfigPluginId = Guid.Empty; + this.HasPluginContribution = false; + } /// /// Resets the configuration property to its default value. /// - public void Reset() + private void Reset() { var configInstance = this.ConfigSelection.Compile().Invoke(SETTINGS_MANAGER.ConfigurationData); var memberExpression = this.PropertyExpression.GetMemberExpression(); diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs index 99b95203..e4cf5f2e 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs @@ -581,6 +581,90 @@ public static partial class ManagedConfiguration return HandleParsedValue(configPluginId, dryRun, successful, configMeta, configuredValue); } + + /// + /// Attempts to process additive plugin contributions for enum set settings from a Lua table. + /// The contributed values are merged into the existing set, and the setting remains unlocked + /// so users can add additional values. + /// + /// The ID of the related configuration plugin. + /// The Lua table containing the settings to process. + /// The expression to select the configuration class. + /// The expression to select the property within the configuration class. + /// When true, the method will not apply any changes but only check if the configuration can be read. + /// The type of the configuration class. + /// The type of the property within the configuration class. It is also the type of the set + /// elements, which must be an enum. + /// True when the configuration was successfully processed, otherwise false. + public static bool TryProcessConfigurationWithPluginContribution( + Expression> configSelection, + Expression>> propertyExpression, + Guid configPluginId, + LuaTable settings, + bool dryRun) + where TValue : Enum + { + // + // Handle configured enum sets (additive merge) + // + + // Check if that configuration was registered: + if (!TryGet(configSelection, propertyExpression, out var configMeta)) + return false; + + var successful = false; + var configuredValue = new HashSet(); + + // Step 1 -- try to read the Lua value (we expect a table) out of the Lua table: + if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) && + configuredLuaList.Type is LuaValueType.Table && + configuredLuaList.TryRead(out var valueTable)) + { + // Determine the length of the Lua table and prepare a set to hold the parsed values: + var len = valueTable.ArrayLength; + var set = new HashSet(len); + + // Iterate over each entry in the Lua table: + for (var index = 1; index <= len; index++) + { + // Retrieve the Lua value at the current index: + var value = valueTable[index]; + + // Step 2 -- try to read the Lua value as a string: + if (value.Type is LuaValueType.String && value.TryRead(out var configuredLuaValueText)) + { + // Step 3 -- try to parse the string as the target type: + if (Enum.TryParse(typeof(TValue), configuredLuaValueText, true, out var configuredEnum)) + set.Add((TValue)configuredEnum); + } + } + + configuredValue = set; + successful = true; + } + + if (dryRun) + return successful; + + if (successful) + { + var configInstance = configSelection.Compile().Invoke(SETTINGS_MANAGER.ConfigurationData); + var currentValue = propertyExpression.Compile().Invoke(configInstance); + var merged = new HashSet(currentValue); + merged.UnionWith(configuredValue); + configMeta.SetValue(merged); + configMeta.SetPluginContribution(new HashSet(configuredValue), configPluginId); + } + else if (configMeta.HasPluginContribution && configMeta.PluginContributionByConfigPluginId == configPluginId) + { + configMeta.ClearPluginContribution(); + } + + if (configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId) + configMeta.UnlockConfiguration(); + + return successful; + } /// /// Attempts to process the configuration settings from a Lua table for string set types. @@ -744,12 +828,12 @@ public static partial class ManagedConfiguration // Case: the setting was configured, and we could read the value successfully. // - // Set the configured value and lock the managed state: + // Set the configured value and lock the configuration: configMeta.SetValue(configuredValue); - configMeta.LockManagedState(configPluginId); + configMeta.LockConfiguration(configPluginId); break; - case false when configMeta.IsLocked && configMeta.MangedByConfigPluginId == configPluginId: + case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == configPluginId: // // Case: the setting was configured previously, but we could not read the value successfully. // This happens when the setting was removed from the configuration plugin. We handle that @@ -757,10 +841,10 @@ public static partial class ManagedConfiguration // // The other case, when the setting was locked and managed by a different configuration plugin, // is handled by the IsConfigurationLeftOver method, which checks if the configuration plugin - // is still available. If it is not available, it resets the managed state of the + // is still available. If it is not available, it resets the locked state of the // configuration setting, allowing it to be reconfigured by a different plugin or left unchanged. // - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); break; case false: diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs index 5cc7a700..363cccc1 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs @@ -9,6 +9,7 @@ namespace AIStudio.Settings; public static partial class ManagedConfiguration { private static readonly ConcurrentDictionary METADATA = new(); + private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); /// /// Attempts to retrieve the configuration metadata for a given configuration selection and @@ -251,13 +252,13 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); return true; } @@ -272,13 +273,13 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); return true; } @@ -296,13 +297,13 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); return true; } @@ -319,13 +320,13 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); return true; } @@ -340,13 +341,38 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); + return true; + } + + return false; + } + + /// + /// Checks if a plugin contribution is left over from a configuration plugin that is no longer available. + /// If so, it clears the contribution and returns true. + /// + public static bool IsPluginContributionLeftOver( + Expression> configSelection, + Expression>> propertyExpression, + IEnumerable availablePlugins) + { + if (!TryGet(configSelection, propertyExpression, out var configMeta)) + return false; + + if (!configMeta.HasPluginContribution || configMeta.PluginContributionByConfigPluginId == Guid.Empty) + return false; + + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.PluginContributionByConfigPluginId); + if (plugin is null) + { + configMeta.ClearPluginContribution(); return true; } @@ -361,13 +387,13 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; - if (configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; - var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId); + var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); if (plugin is null) { - configMeta.ResetManagedState(); + configMeta.ResetLockedConfiguration(); return true; } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 15e845c1..b4007b9d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -121,8 +121,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: preview features visibility ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun); - // Config: enabled preview features - ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.EnabledPreviewFeatures, this.Id, settingsTable, dryRun); + // Config: enabled preview features (plugin contribution; users can enable additional features) + ManagedConfiguration.TryProcessConfigurationWithPluginContribution(x => x.App, x => x.EnabledPreviewFeatures, this.Id, settingsTable, dryRun); // Config: hide some assistants? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HiddenAssistants, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 40bc37c0..be6de578 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -219,6 +219,9 @@ public static partial class PluginFactory if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + if(ManagedConfiguration.IsPluginContributionLeftOver(x => x.App, x => x.EnabledPreviewFeatures, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + // Check for the transcription provider: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UseTranscriptionProvider, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; 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 227c02fe..2865df75 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -11,6 +11,7 @@ - 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 configuration plugins by making `EnabledPreviewFeatures` additive rather than exclusive. Users can now enable additional preview features without being restricted to those selected by the configuration plugin. - Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. - Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. From af72a45035a61752de4a0046debbd035c31399ee Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 20 Feb 2026 14:13:10 +0100 Subject: [PATCH 08/14] Fixed handling of paths in Pandoc exports (#674) --- .../Tools/PandocProcessBuilder.cs | 60 +++++++++++-------- .../wwwroot/changelog/v26.2.2.md | 1 + 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs b/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs index c2c404a7..6d95ad9f 100644 --- a/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs +++ b/app/MindWork AI Studio/Tools/PandocProcessBuilder.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Reflection; -using System.Text; using AIStudio.Tools.Metadata; using AIStudio.Tools.Services; @@ -74,36 +73,49 @@ public sealed class PandocProcessBuilder public async Task BuildAsync(RustService rustService) { - var sbArguments = new StringBuilder(); - - if (this.useStandaloneMode) - sbArguments.Append(" --standalone "); - - if(!string.IsNullOrWhiteSpace(this.providedInputFile)) - sbArguments.Append(this.providedInputFile); - - if(!string.IsNullOrWhiteSpace(this.providedInputFormat)) - sbArguments.Append($" -f {this.providedInputFormat}"); - - if(!string.IsNullOrWhiteSpace(this.providedOutputFormat)) - sbArguments.Append($" -t {this.providedOutputFormat}"); - - foreach (var additionalArgument in this.additionalArguments) - sbArguments.Append($" {additionalArgument}"); - - if(!string.IsNullOrWhiteSpace(this.providedOutputFile)) - sbArguments.Append($" -o {this.providedOutputFile}"); - var pandocExecutable = await PandocExecutablePath(rustService); - return new (new ProcessStartInfo + var startInfo = new ProcessStartInfo { FileName = pandocExecutable.Executable, - Arguments = sbArguments.ToString(), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true - }, pandocExecutable.IsLocalInstallation); + }; + + // Use argument tokens instead of a single command string so paths with spaces + // or Unicode characters are passed to Pandoc unchanged on all platforms. + if (this.useStandaloneMode) + startInfo.ArgumentList.Add("--standalone"); + + if (!string.IsNullOrWhiteSpace(this.providedInputFile)) + startInfo.ArgumentList.Add(this.providedInputFile); + + if (!string.IsNullOrWhiteSpace(this.providedInputFormat)) + { + startInfo.ArgumentList.Add("-f"); + startInfo.ArgumentList.Add(this.providedInputFormat); + } + + if (!string.IsNullOrWhiteSpace(this.providedOutputFormat)) + { + startInfo.ArgumentList.Add("-t"); + startInfo.ArgumentList.Add(this.providedOutputFormat); + } + + foreach (var additionalArgument in this.additionalArguments) + { + if (!string.IsNullOrWhiteSpace(additionalArgument)) + startInfo.ArgumentList.Add(additionalArgument); + } + + if (!string.IsNullOrWhiteSpace(this.providedOutputFile)) + { + startInfo.ArgumentList.Add("-o"); + startInfo.ArgumentList.Add(this.providedOutputFile); + } + + return new(startInfo, pandocExecutable.IsLocalInstallation); } /// 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 2865df75..713854bf 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -17,6 +17,7 @@ - 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 an issue where in some places "No profile" was displayed instead of the localized text. - 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. +- Fixed a bug in the Microsoft Word export via Pandoc where target paths containing spaces or Unicode characters could be split into invalid command arguments, resulting in export failures. - Fixed the Google Gemini model API. Switched to the default OpenAI-compatible API to retrieve the model list after Google changed the previous API, which stopped working. - Upgraded to .NET 9.0.13 & Rust 1.93.1. - Upgraded dependencies. \ No newline at end of file From a8951fe58a582f1935ce9b11407bec1d4bab9034 Mon Sep 17 00:00:00 2001 From: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:32:54 +0100 Subject: [PATCH 09/14] Added embedding API (#654) Co-authored-by: Thorsten Sommer --- .../Assistants/I18N/allTexts.lua | 33 ++++++++ .../Settings/SettingsPanelEmbeddings.razor | 3 + .../Settings/SettingsPanelEmbeddings.razor.cs | 46 +++++++++++ .../Dialogs/EmbeddingResultDialog.razor | 22 ++++++ .../Dialogs/EmbeddingResultDialog.razor.cs | 21 +++++ .../plugin.lua | 39 ++++++++- .../plugin.lua | 39 ++++++++- .../AlibabaCloud/ProviderAlibabaCloud.cs | 7 ++ .../Provider/Anthropic/ProviderAnthropic.cs | 6 ++ .../Provider/BaseProvider.cs | 79 +++++++++++++++++++ .../Provider/DeepSeek/ProviderDeepSeek.cs | 6 ++ .../Provider/EmbeddingData.cs | 12 +++ .../Provider/EmbeddingResponse.cs | 14 ++++ .../Provider/EmbeddingUsage.cs | 11 +++ .../Provider/Fireworks/ProviderFireworks.cs | 6 ++ .../Provider/GWDG/ProviderGWDG.cs | 6 ++ .../Provider/Google/GoogleEmbedding.cs | 6 ++ .../Google/GoogleEmbeddingResponse.cs | 30 +++++++ .../Provider/Google/ProviderGoogle.cs | 72 +++++++++++++++++ .../Provider/Groq/ProviderGroq.cs | 6 ++ .../Provider/Helmholtz/ProviderHelmholtz.cs | 7 ++ .../HuggingFace/ProviderHuggingFace.cs | 6 ++ app/MindWork AI Studio/Provider/IProvider.cs | 10 +++ .../Provider/Mistral/ProviderMistral.cs | 7 ++ app/MindWork AI Studio/Provider/NoProvider.cs | 2 + .../Provider/OpenAI/ProviderOpenAI.cs | 7 ++ .../Provider/OpenRouter/ProviderOpenRouter.cs | 7 ++ .../Provider/Perplexity/ProviderPerplexity.cs | 6 ++ .../Provider/SelfHosted/HostExtensions.cs | 5 ++ .../Provider/SelfHosted/ProviderSelfHosted.cs | 7 ++ .../Provider/X/ProviderX.cs | 6 ++ .../wwwroot/changelog/v26.2.2.md | 1 + 32 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor.cs create mode 100644 app/MindWork AI Studio/Provider/EmbeddingData.cs create mode 100644 app/MindWork AI Studio/Provider/EmbeddingResponse.cs create mode 100644 app/MindWork AI Studio/Provider/EmbeddingUsage.cs create mode 100644 app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs create mode 100644 app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 0c4509e8..02233950 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2170,9 +2170,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] -- Administration settings are not visible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" +-- Embedding Result +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1387042335"] = "Embedding Result" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" +-- Embed text +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1644934561"] = "Embed text" + +-- Test Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Test Embedding Provider" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" @@ -2185,6 +2194,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T18253 -- Add Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T190634634"] = "Add Embedding Provider" +-- Add text that should be embedded: +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1992646324"] = "Add text that should be embedded:" + +-- Embedding Vector (one dimension per line) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2174876961"] = "Embedding Vector (one dimension per line)" + -- Model UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2189814010"] = "Model" @@ -2194,6 +2209,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T24199 -- Name UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T266367750"] = "Name" +-- No embedding was returned. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T291969"] = "No embedding was returned." + -- Configured Embedding Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T305753126"] = "Configured Embedding Providers" @@ -2203,6 +2221,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T32512 -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3267849393"] = "Edit" +-- Close +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3448155331"] = "Close" + -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3865031940"] = "Actions" @@ -2224,6 +2245,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T51130 -- Open Dashboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223861"] = "Open Dashboard" +-- Test +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T805092869"] = "Test" + +-- Example text to embed +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T816748904"] = "Example text to embed" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" @@ -3328,6 +3355,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900237532"] = "Pro -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900713019"] = "Cancel" +-- Embedding Vector +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Embedding Vector" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Close" + -- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system." diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index e68fdeee..9d14a99a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -62,6 +62,9 @@ + + + } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 0f78bb97..775b2ad9 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -1,4 +1,6 @@ +using System.Globalization; using AIStudio.Dialogs; +using AIStudio.Provider; using AIStudio.Settings; using Microsoft.AspNetCore.Components; @@ -134,4 +136,48 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase await this.AvailableEmbeddingProvidersChanged.InvokeAsync(this.AvailableEmbeddingProviders); } + + private async Task TestEmbeddingProvider(EmbeddingProvider provider) + { + var dialogParameters = new DialogParameters + { + { x => x.ConfirmText, T("Embed text") }, + { x => x.InputHeaderText, T("Add text that should be embedded:") }, + { x => x.UserInput, T("Example text to embed") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Test Embedding Provider"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + + var inputText = dialogResult.Data as string; + if (string.IsNullOrWhiteSpace(inputText)) + return; + + var embeddingProvider = provider.CreateProvider(); + var embeddings = await embeddingProvider.EmbedTextAsync(provider.Model, this.SettingsManager, default, new List { inputText }); + + if (embeddings.Count == 0) + { + await this.DialogService.ShowMessageBox(T("Embedding Result"), T("No embedding was returned."), T("Close")); + return; + } + + var vector = embeddings.FirstOrDefault(); + if (vector is null || vector.Count == 0) + { + await this.DialogService.ShowMessageBox(T("Embedding Result"), T("No embedding was returned."), T("Close")); + return; + } + + var resultText = string.Join(Environment.NewLine, vector.Select(value => value.ToString("G9", CultureInfo.InvariantCulture))); + var resultParameters = new DialogParameters + { + { x => x.ResultText, resultText }, + { x => x.ResultLabel, T("Embedding Vector (one dimension per line)") }, + }; + + await this.DialogService.ShowAsync(T("Embedding Result"), resultParameters, DialogOptions.FULLSCREEN); + } } diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor b/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor new file mode 100644 index 00000000..8e1408ef --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor @@ -0,0 +1,22 @@ +@inherits MSGComponentBase + + + + + + + + @T("Close") + + + diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor.cs b/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor.cs new file mode 100644 index 00000000..96830edf --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/EmbeddingResultDialog.razor.cs @@ -0,0 +1,21 @@ +using AIStudio.Components; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +public partial class EmbeddingResultDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public string ResultText { get; set; } = string.Empty; + + [Parameter] + public string ResultLabel { get; set; } = string.Empty; + + private string ResultLabelText => string.IsNullOrWhiteSpace(this.ResultLabel) ? T("Embedding Vector") : this.ResultLabel; + + private void Close() => this.MudDialog.Close(); +} 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 d95f1a6a..c518d439 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 @@ -1812,7 +1812,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Demokratisie -- 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." --- 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. +-- 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. 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. @@ -2172,9 +2172,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] -- Administration settings are not visible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Die Optionen für die Administration sind nicht sichtbar." +-- Embedding Result +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1387042335"] = "Einbettungsergebnis" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Löschen" +-- Embed text +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1644934561"] = "Text einbetten" + +-- Test Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Anbieter für Einbettung testen" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Einbettung hinzufügen" @@ -2187,6 +2196,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T18253 -- Add Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T190634634"] = "Einbettungsanbieter hinzufügen" +-- Add text that should be embedded: +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1992646324"] = "Text zum Einbetten eingeben:" + +-- Embedding Vector (one dimension per line) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2174876961"] = "Einbettungsvektor (eine Dimension pro Zeile)" + -- Model UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2189814010"] = "Modell" @@ -2196,6 +2211,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T24199 -- Name UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T266367750"] = "Name" +-- No embedding was returned. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T291969"] = "Es wurde keine Einbettung zurückgegeben." + -- Configured Embedding Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T305753126"] = "Konfigurierte Anbieter für Einbettungen" @@ -2205,6 +2223,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T32512 -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3267849393"] = "Bearbeiten" +-- Close +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3448155331"] = "Schließen" + -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3865031940"] = "Aktionen" @@ -2226,6 +2247,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T51130 -- Open Dashboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223861"] = "Dashboard öffnen" +-- Test +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T805092869"] = "Testen" + +-- Example text to embed +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T816748904"] = "Beispieltext zum Einbetten" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Anbieter" @@ -2448,7 +2475,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Demokratisierung -- 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. +-- 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. 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. @@ -3330,6 +3357,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900237532"] = "Anb -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900713019"] = "Abbrechen" +-- Embedding Vector +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Einbettungsvektor" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Schließen" + -- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Leider ist die GPL-Lizenz von Pandoc nicht mit der Lizenz von AI Studio kompatibel. Software unter der GPL-Lizenz ist jedoch kostenlos und frei nutzbar. Sie müssen die GPL-Lizenz akzeptieren, bevor wir Pandoc automatisch für Sie herunterladen und installieren können (empfohlen). Alternativ können Sie Pandoc auch selbst herunterladen – entweder mit den untenstehenden Anweisungen oder auf anderem Weg, zum Beispiel über den Paketmanager Ihres Betriebssystems." @@ -4995,7 +5028,7 @@ 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. +-- 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. 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 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 688cb8d0..a4fdfd5c 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 @@ -1812,7 +1812,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratizat -- 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." --- 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. +-- 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. 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. @@ -2172,9 +2172,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] -- Administration settings are not visible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" +-- Embedding Result +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1387042335"] = "Embedding Result" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" +-- Embed text +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1644934561"] = "Embed text" + +-- Test Embedding Provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1655784761"] = "Test Embedding Provider" + -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" @@ -2187,6 +2196,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T18253 -- Add Embedding Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T190634634"] = "Add Embedding Provider" +-- Add text that should be embedded: +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1992646324"] = "Add text that should be embedded:" + +-- Embedding Vector (one dimension per line) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2174876961"] = "Embedding Vector (one dimension per line)" + -- Model UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T2189814010"] = "Model" @@ -2196,6 +2211,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T24199 -- Name UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T266367750"] = "Name" +-- No embedding was returned. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T291969"] = "No embedding was returned." + -- Configured Embedding Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T305753126"] = "Configured Embedding Providers" @@ -2205,6 +2223,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T32512 -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3267849393"] = "Edit" +-- Close +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3448155331"] = "Close" + -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3865031940"] = "Actions" @@ -2226,6 +2247,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T51130 -- Open Dashboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223861"] = "Open Dashboard" +-- Test +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T805092869"] = "Test" + +-- Example text to embed +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T816748904"] = "Example text to embed" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" @@ -2448,7 +2475,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization -- 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. +-- 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. 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. @@ -3330,6 +3357,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900237532"] = "Pro -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T900713019"] = "Cancel" +-- Embedding Vector +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Embedding Vector" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Close" + -- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system." @@ -4995,7 +5028,7 @@ 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. +-- 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. 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 diff --git a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs index de46e95b..3535809d 100644 --- a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs +++ b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs @@ -86,6 +86,13 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C { return Task.FromResult(string.Empty); } + + /// + public override async Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index b536ee4d..5eb8fe2b 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -113,6 +113,12 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " { return Task.FromResult(string.Empty); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 0cf8a362..4acefc62 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -98,6 +99,9 @@ public abstract class BaseProvider : IProvider, ISecretId /// public abstract Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); + /// + public abstract Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts); + /// public abstract Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); @@ -645,6 +649,81 @@ public abstract class BaseProvider : IProvider, ISecretId } } + protected async Task>> PerformStandardTextEmbeddingRequest(RequestedSecret requestedSecret, Model embeddingModel, Host host = Host.NONE, CancellationToken token = default, params List texts) + { + try + { + // + // Add the model name to the form data. Ensure that a model name is always provided. + // Otherwise, the StringContent constructor will throw an exception. + // + var modelName = embeddingModel.Id; + if (string.IsNullOrWhiteSpace(modelName)) + modelName = "placeholder"; + + // Prepare the HTTP embedding request: + var payload = new + { + model = modelName, + input = texts, + encoding_format = "float" + }; + + var embeddingRequest = JsonSerializer.Serialize(payload, JSON_SERIALIZER_OPTIONS); + using var request = new HttpRequestMessage(HttpMethod.Post, host.EmbeddingURL()); + + // Handle the authorization header based on the provider: + switch (this.Provider) + { + case LLMProviders.SELF_HOSTED: + if(requestedSecret.Success) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + + break; + + default: + if(!requestedSecret.Success) + { + this.logger.LogError("No valid API key available for embedding request."); + return []; + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + break; + } + + // Set the content: + request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); + using var response = await this.httpClient.SendAsync(request, token); + var responseBody = response.Content.ReadAsStringAsync(token).Result; + + if (!response.IsSuccessStatusCode) + { + this.logger.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); + return []; + } + + var embeddingResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); + if (embeddingResponse is { Data: not null }) + { + return embeddingResponse.Data + .Select(d => d.Embedding?.ToArray() ?? []) + .Cast>() + .ToArray(); + } + else + { + this.logger.LogError("Was not able to deserialize the embedding response."); + return []; + } + } + catch (Exception e) + { + this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message); + return []; + } + } + /// /// Parse and convert API parameters from a provided JSON string into a dictionary, /// optionally merging additional parameters and removing specific keys. diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index ce33f288..e1ae306a 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -86,6 +86,12 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, "h { return Task.FromResult(string.Empty); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/EmbeddingData.cs b/app/MindWork AI Studio/Provider/EmbeddingData.cs new file mode 100644 index 00000000..35faa13d --- /dev/null +++ b/app/MindWork AI Studio/Provider/EmbeddingData.cs @@ -0,0 +1,12 @@ +// ReSharper disable CollectionNeverUpdated.Global +namespace AIStudio.Provider; + +// ReSharper disable once ClassNeverInstantiated.Global +public sealed record EmbeddingData +{ + public string? Object { get; set; } + + public List? Embedding { get; set; } + + public int? Index { get; set; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/EmbeddingResponse.cs b/app/MindWork AI Studio/Provider/EmbeddingResponse.cs new file mode 100644 index 00000000..6a6c6a86 --- /dev/null +++ b/app/MindWork AI Studio/Provider/EmbeddingResponse.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Provider; + +public sealed record EmbeddingResponse +{ + public string? Id { get; init; } + + public string? Object { get; init; } + + public List? Data { get; init; } + + public string? Model { get; init; } + + public EmbeddingUsage? Usage { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/EmbeddingUsage.cs b/app/MindWork AI Studio/Provider/EmbeddingUsage.cs new file mode 100644 index 00000000..3087babe --- /dev/null +++ b/app/MindWork AI Studio/Provider/EmbeddingUsage.cs @@ -0,0 +1,11 @@ +// ReSharper disable ClassNeverInstantiated.Global +namespace AIStudio.Provider; + +public sealed record EmbeddingUsage +{ + public int? PromptTokens { get; set; } + + public int? TotalTokens { get; set; } + + public int? CompletionTokens { get; set; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index 1eb21894..2254b7ad 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -88,6 +88,12 @@ public class ProviderFireworks() : BaseProvider(LLMProviders.FIREWORKS, "https:/ var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs index 2b7e4dcb..41e19fa9 100644 --- a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs +++ b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs @@ -87,6 +87,12 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, "https://ch var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs new file mode 100644 index 00000000..9a7d9b38 --- /dev/null +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Provider.Google; + +public sealed record GoogleEmbedding +{ + public List? Values { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs new file mode 100644 index 00000000..24d9c175 --- /dev/null +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AIStudio.Provider.Google; + +public sealed record GoogleEmbeddingResponse +{ + [JsonConverter(typeof(GoogleEmbeddingListConverter))] + public List? Embedding { get; init; } + + private sealed class GoogleEmbeddingListConverter : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + var single = JsonSerializer.Deserialize(ref reader, options); + return single is null ? new() : new() { single }; + } + + if (reader.TokenType == JsonTokenType.StartArray) + return JsonSerializer.Deserialize>(ref reader, options) ?? new(); + + throw new JsonException("Expected object or array for embedding."); + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 97157080..8a86fcbe 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -87,6 +87,78 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, "https://gener { return Task.FromResult(string.Empty); } + + /// + public override async Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + try + { + var modelName = embeddingModel.Id; + if (string.IsNullOrWhiteSpace(modelName)) + { + LOGGER.LogError("No model name provided for embedding request."); + return []; + } + + if (modelName.StartsWith("models/", StringComparison.OrdinalIgnoreCase)) + modelName = modelName.Substring("models/".Length); + + if (!requestedSecret.Success) + { + LOGGER.LogError("No valid API key available for embedding request."); + return []; + } + + // Prepare the Google Gemini embedding request: + var payload = new + { + content = new + { + parts = texts.Select(text => new { text }).ToArray() + }, + + taskType = "SEMANTIC_SIMILARITY" + }; + + var embeddingRequest = JsonSerializer.Serialize(payload, JSON_SERIALIZER_OPTIONS); + var embedUrl = $"https://generativelanguage.googleapis.com/v1beta/models/{modelName}:embedContent"; + using var request = new HttpRequestMessage(HttpMethod.Post, embedUrl); + request.Headers.Add("x-goog-api-key", await requestedSecret.Secret.Decrypt(ENCRYPTION)); + + // Set the content: + request.Content = new StringContent(embeddingRequest, Encoding.UTF8, "application/json"); + + using var response = await this.httpClient.SendAsync(request, token); + var responseBody = await response.Content.ReadAsStringAsync(token); + + if (!response.IsSuccessStatusCode) + { + LOGGER.LogError("Embedding request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody); + return []; + } + + var embeddingResponse = JsonSerializer.Deserialize(responseBody, JSON_SERIALIZER_OPTIONS); + if (embeddingResponse is { Embedding: not null }) + { + return embeddingResponse.Embedding + .Select(d => d.Values?.ToArray() ?? []) + .Cast>() + .ToArray(); + } + else + { + LOGGER.LogError("Was not able to deserialize the embedding response."); + return []; + } + + } + catch (Exception e) + { + LOGGER.LogError("Failed to perform embedding request: '{Message}'.", e.Message); + return []; + } + } /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index 07cdb390..8f938667 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -87,6 +87,12 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, "https://api.groq. { return Task.FromResult(string.Empty); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index ec5fca2c..070597a3 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -86,6 +86,13 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, " { return Task.FromResult(string.Empty); } + + /// + public override async Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index a05ca11e..f2e8c380 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -91,6 +91,12 @@ public sealed class ProviderHuggingFace : BaseProvider { return Task.FromResult(string.Empty); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/IProvider.cs b/app/MindWork AI Studio/Provider/IProvider.cs index 5c390074..ef15dd21 100644 --- a/app/MindWork AI Studio/Provider/IProvider.cs +++ b/app/MindWork AI Studio/Provider/IProvider.cs @@ -59,6 +59,16 @@ public interface IProvider /// The cancellation token. /// >The transcription result. public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default); + + /// + /// Embed a text file. + /// + /// The model to use for embedding. + /// The settings manager instance to use. + /// The cancellation token. + /// /// A single string or a list of strings to embed. + /// >The embedded text as a single vector or as a list of vectors. + public Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts); /// /// Load all possible text models that can be used with this provider. diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index 6685e6d6..f4cb07f4 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -89,6 +89,13 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); } + /// + public override async Task>> EmbedTextAsync(Provider.Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } + /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { diff --git a/app/MindWork AI Studio/Provider/NoProvider.cs b/app/MindWork AI Studio/Provider/NoProvider.cs index a650ac34..3fc8459c 100644 --- a/app/MindWork AI Studio/Provider/NoProvider.cs +++ b/app/MindWork AI Studio/Provider/NoProvider.cs @@ -40,6 +40,8 @@ public class NoProvider : IProvider public Task TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default) => Task.FromResult(string.Empty); + public Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) => Task.FromResult>>([]); + public IReadOnlyCollection GetModelCapabilities(Model model) => [ Capability.NONE ]; #endregion diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index d2d0b32b..e5b6ebfd 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -224,6 +224,13 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, "https var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER); return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token); } + + /// + public override async Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index ca8ef155..4995cca9 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -94,6 +94,13 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER { return Task.FromResult(string.Empty); } + + /// + public override async Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs index 691dcdd5..4c73dc2d 100644 --- a/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs +++ b/app/MindWork AI Studio/Provider/Perplexity/ProviderPerplexity.cs @@ -94,6 +94,12 @@ public sealed class ProviderPerplexity() : BaseProvider(LLMProviders.PERPLEXITY, { return Task.FromResult(string.Empty); } + + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } /// public override Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Provider/SelfHosted/HostExtensions.cs b/app/MindWork AI Studio/Provider/SelfHosted/HostExtensions.cs index 6c475273..25dc07ca 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/HostExtensions.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/HostExtensions.cs @@ -30,6 +30,11 @@ public static class HostExtensions _ => "audio/transcriptions", }; + public static string EmbeddingURL(this Host host) => host switch + { + _ => "embeddings", + }; + public static bool IsChatSupported(this Host host) { switch (host) diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index a1e411e1..9b3d6d67 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -95,6 +95,13 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, host, token); } + /// + public override async Task>> EmbedTextAsync(Provider.Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + } + public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { try diff --git a/app/MindWork AI Studio/Provider/X/ProviderX.cs b/app/MindWork AI Studio/Provider/X/ProviderX.cs index a0510dd6..21d6e2ca 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -88,6 +88,12 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, "https://api.x.ai return Task.FromResult(string.Empty); } + /// + public override Task>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) + { + return Task.FromResult>>([]); + } + /// public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { 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 713854bf..36f45095 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -1,5 +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. +- Added an option in the embedding providers table to test the embedding process. - 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. From e7707ba482575a5369414862a5f1748b2aacdcc0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 20 Feb 2026 15:46:05 +0100 Subject: [PATCH 10/14] Upgraded to Qdrant v1.17.0 (#673) --- app/MindWork AI Studio/MindWork AI Studio.csproj | 2 +- app/MindWork AI Studio/packages.lock.json | 6 +++--- metadata.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index 0d668b8b..d48580d0 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -52,7 +52,7 @@ - + diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index ee106ea9..5770dcec 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -64,9 +64,9 @@ }, "Qdrant.Client": { "type": "Direct", - "requested": "[1.16.1, )", - "resolved": "1.16.1", - "contentHash": "EJo50JXTdjY2JOUphCFLXoHukI/tz/ykLCmMnQHUjsKT22ZfL0XIdEziHOC3vjw2SOoY8WDVQ+AxixEonejOZA==", + "requested": "[1.17.0, )", + "resolved": "1.17.0", + "contentHash": "QFNtVu4Kiz6NHAAi2UQk+Ia64/qyX1NMecQGIBGnKqFOlpnxI3OCCBRBKXWGPk/c+4vAmR3Dj+cQ9apqX0zU8A==", "dependencies": { "Google.Protobuf": "3.31.0", "Grpc.Net.Client": "2.71.0" diff --git a/metadata.txt b/metadata.txt index 0fe67ec5..1736df95 100644 --- a/metadata.txt +++ b/metadata.txt @@ -9,4 +9,4 @@ 8f9cd40d060, release osx-arm64 144.0.7543.0 -1.16.3 \ No newline at end of file +1.17.0 \ No newline at end of file From 6c4507ef820335203c864b98032f3f286657a678 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 22 Feb 2026 15:09:51 +0100 Subject: [PATCH 11/14] Improved API key retrieval for local embedding (#675) --- .../Provider/SelfHosted/ProviderSelfHosted.cs | 4 ++-- .../Tools/Services/RustService.APIKeys.cs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 9b3d6d67..8204fa6c 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -98,8 +98,8 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide /// public override async Task>> EmbedTextAsync(Provider.Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List texts) { - var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER); - return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts); + var requestedSecret = await RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER, isTrying: true); + return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, host, token: token, texts: texts); } public override async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs index abc06b03..e2a8b88e 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs @@ -27,7 +27,11 @@ public sealed partial class RustService if (!secret.Success && !isTrying) this.logger!.LogError($"Failed to get the API key for '{prefix}::{secretId.SecretId}::{secretId.SecretName}::api_key': '{secret.Issue}'"); - this.logger!.LogDebug($"Successfully retrieved the API key for '{prefix}::{secretId.SecretId}::{secretId.SecretName}::api_key'."); + if (secret.Success) + this.logger!.LogDebug($"Successfully retrieved the API key for '{prefix}::{secretId.SecretId}::{secretId.SecretName}::api_key'."); + else if (isTrying) + this.logger!.LogDebug($"No API key configured for '{prefix}::{secretId.SecretId}::{secretId.SecretName}::api_key' (try mode): '{secret.Issue}'"); + return secret; } From 09df19e6f58b3e67db07748e07425914dc8a60f7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 22 Feb 2026 15:20:05 +0100 Subject: [PATCH 12/14] Prepared release v26.2.2 (#676) --- app/MindWork AI Studio/Components/Changelog.Logs.cs | 1 + app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 4 ++-- app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md | 1 + metadata.txt | 8 ++++---- runtime/Cargo.lock | 2 +- runtime/Cargo.toml | 2 +- runtime/tauri.conf.json | 2 +- 7 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md diff --git a/app/MindWork AI Studio/Components/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Changelog.Logs.cs index fec0b88e..95070983 100644 --- a/app/MindWork AI Studio/Components/Changelog.Logs.cs +++ b/app/MindWork AI Studio/Components/Changelog.Logs.cs @@ -13,6 +13,7 @@ public partial class Changelog public static readonly Log[] LOGS = [ + new (234, "v26.2.2, build 234 (2026-02-22 14:16 UTC)", "v26.2.2.md"), new (233, "v26.2.1, build 233 (2026-02-01 19:16 UTC)", "v26.2.1.md"), new (232, "v26.1.2, build 232 (2026-01-25 14:05 UTC)", "v26.1.2.md"), new (231, "v26.1.1, build 231 (2026-01-11 15:53 UTC)", "v26.1.1.md"), 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 36f45095..0f00b7db 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -1,4 +1,4 @@ -# v26.2.2, build 234 (2026-02-xx xx:xx UTC) +# v26.2.2, build 234 (2026-02-22 14:14 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. - Added an option in the embedding providers table to test the embedding process. - Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings. @@ -18,7 +18,7 @@ - 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 an issue where in some places "No profile" was displayed instead of the localized text. - 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. -- Fixed a bug in the Microsoft Word export via Pandoc where target paths containing spaces or Unicode characters could be split into invalid command arguments, resulting in export failures. +- Fixed a bug in the Microsoft Word export via Pandoc where target paths containing spaces or Unicode characters were split into invalid command arguments, causing export failures. Thanks to Bernhard for reporting this issue. - Fixed the Google Gemini model API. Switched to the default OpenAI-compatible API to retrieve the model list after Google changed the previous API, which stopped working. - Upgraded to .NET 9.0.13 & Rust 1.93.1. - Upgraded dependencies. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md new file mode 100644 index 00000000..d0cfc3a3 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -0,0 +1 @@ +# v26.3.1, build 235 (2026-03-xx xx:xx UTC) diff --git a/metadata.txt b/metadata.txt index 1736df95..77e953d5 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,12 +1,12 @@ -26.2.1 -2026-02-01 19:16:01 UTC -233 +26.2.2 +2026-02-22 14:14:47 UTC +234 9.0.114 (commit 4c5aac3d56) 9.0.13 (commit 9ecbfd4f3f) 1.93.1 (commit 01f6ddf75) 8.15.0 1.8.1 -8f9cd40d060, release +3eb367d4c9e, release osx-arm64 144.0.7543.0 1.17.0 \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 5fad3be7..407a5627 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2789,7 +2789,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "26.2.1" +version = "26.2.2" dependencies = [ "aes", "arboard", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 835e632f..b3c1b32e 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindwork-ai-studio" -version = "26.2.1" +version = "26.2.2" edition = "2021" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 90471450..27e0aae7 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -6,7 +6,7 @@ }, "package": { "productName": "MindWork AI Studio", - "version": "26.2.1" + "version": "26.2.2" }, "tauri": { "allowlist": { From 685f95245b3d3df6a4c0a6f676aacdd4a7aeb52d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 25 Feb 2026 19:30:46 +0100 Subject: [PATCH 13/14] Improved logging (#678) --- .../RustAvailabilityMonitorService.cs | 2 +- .../Tools/Services/RustService.OS.cs | 33 +++++++-- .../Tools/Services/RustService.cs | 3 + .../Tools/TerminalLogger.cs | 33 ++++++--- .../wwwroot/changelog/v26.3.1.md | 4 + runtime/src/dotnet.rs | 60 ++++++++++++++- runtime/src/environment.rs | 73 ++++++++++++++----- 7 files changed, 169 insertions(+), 39 deletions(-) diff --git a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs index 40c22f0f..e4026fd3 100644 --- a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs @@ -100,7 +100,7 @@ public sealed class RustAvailabilityMonitorService : BackgroundService, IMessage { try { - await this.rustService.ReadUserLanguage(); + await this.rustService.ReadUserLanguage(forceRequest: true); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs index 215b3a02..0b81ccfe 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs @@ -2,15 +2,34 @@ public sealed partial class RustService { - public async Task ReadUserLanguage() + public async Task ReadUserLanguage(bool forceRequest = false) { - var response = await this.http.GetAsync("/system/language"); - if (!response.IsSuccessStatusCode) + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserLanguage)) + return this.cachedUserLanguage; + + await this.userLanguageLock.WaitAsync(); + try { - this.logger!.LogError($"Failed to read the user language from Rust: '{response.StatusCode}'"); - return string.Empty; + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserLanguage)) + return this.cachedUserLanguage; + + var response = await this.http.GetAsync("/system/language"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to read the user language from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + var userLanguage = (await response.Content.ReadAsStringAsync()).Trim(); + if (string.IsNullOrWhiteSpace(userLanguage)) + return string.Empty; + + this.cachedUserLanguage = userLanguage; + return userLanguage; + } + finally + { + this.userLanguageLock.Release(); } - - return await response.Content.ReadAsStringAsync(); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 5d4e2b08..9f495adb 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -17,6 +17,7 @@ public sealed partial class RustService : BackgroundService private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService)); private readonly HttpClient http; + private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { @@ -29,6 +30,7 @@ public sealed partial class RustService : BackgroundService private ILogger? logger; private Encryption? encryptor; + private string? cachedUserLanguage; private readonly string apiPort; private readonly string certificateFingerprint; @@ -88,6 +90,7 @@ public sealed partial class RustService : BackgroundService public override void Dispose() { this.http.Dispose(); + this.userLanguageLock.Dispose(); base.Dispose(); } diff --git a/app/MindWork AI Studio/Tools/TerminalLogger.cs b/app/MindWork AI Studio/Tools/TerminalLogger.cs index f6801e8a..2c20d510 100644 --- a/app/MindWork AI Studio/Tools/TerminalLogger.cs +++ b/app/MindWork AI Studio/Tools/TerminalLogger.cs @@ -13,6 +13,12 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) public const string FORMATTER_NAME = "AI Studio Terminal Logger"; private static RustService? RUST_SERVICE; + + // ReSharper disable FieldCanBeMadeReadOnly.Local + // ReSharper disable ConvertToConstant.Local + private static bool LOG_TO_STDOUT = true; + // ReSharper restore ConvertToConstant.Local + // ReSharper restore FieldCanBeMadeReadOnly.Local // Buffer for early log events before the RustService is available: private static readonly ConcurrentQueue EARLY_LOG_BUFFER = new(); @@ -44,6 +50,10 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) bufferedEvent.StackTrace ); } + + #if !DEBUG + LOG_TO_STDOUT = false; + #endif } public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) @@ -56,19 +66,22 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) var stackTrace = logEntry.Exception?.StackTrace; var colorCode = GetColorForLogLevel(logEntry.LogLevel); - textWriter.Write($"[{colorCode}{timestamp}{ANSI_RESET}] {colorCode}{logLevel}{ANSI_RESET} [{category}] {colorCode}{message}{ANSI_RESET}"); - if (logEntry.Exception is not null) + if (LOG_TO_STDOUT) { - textWriter.Write($" {colorCode}Exception: {exceptionMessage}{ANSI_RESET}"); - if (stackTrace is not null) + textWriter.Write($"[{colorCode}{timestamp}{ANSI_RESET}] {colorCode}{logLevel}{ANSI_RESET} [{category}] {colorCode}{message}{ANSI_RESET}"); + if (logEntry.Exception is not null) { - textWriter.WriteLine(); - foreach (var line in stackTrace.Split('\n')) - textWriter.WriteLine($" {colorCode}{line.TrimEnd()}{ANSI_RESET}"); + textWriter.Write($" {colorCode}Exception: {exceptionMessage}{ANSI_RESET}"); + if (stackTrace is not null) + { + textWriter.WriteLine(); + foreach (var line in stackTrace.Split('\n')) + textWriter.WriteLine($" {colorCode}{line.TrimEnd()}{ANSI_RESET}"); + } } + else + textWriter.WriteLine(); } - else - textWriter.WriteLine(); // Send log event to Rust via API (fire-and-forget): if (RUST_SERVICE is not null) @@ -90,4 +103,4 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) _ => ANSI_RESET }; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index d0cfc3a3..840e2947 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -1 +1,5 @@ # v26.3.1, build 235 (2026-03-xx xx:xx UTC) +- Improved the performance by caching the OS language detection and requesting the user language only once per app start. +- Improved the user-language logging by limiting language detection logs to a single entry per app start. +- Improved the logbook readability by removing non-readable special characters from log entries. +- Improved the logbook reliability by significantly reducing duplicate log entries. \ No newline at end of file diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 338074a0..11cc3db5 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -33,6 +33,59 @@ static DOTNET_INITIALIZED: Lazy> = Lazy::new(|| Mutex::new(false)); pub const PID_FILE_NAME: &str = "mindwork_ai_studio.pid"; const SIDECAR_TYPE:SidecarType = SidecarType::Dotnet; +/// Removes ANSI escape sequences and non-printable control chars from stdout lines. +fn sanitize_stdout_line(line: &str) -> String { + let mut sanitized = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\u{1B}' { + if let Some(next) = chars.peek().copied() { + // CSI sequence: ESC [ ... + if next == '[' { + chars.next(); + for csi_char in chars.by_ref() { + let code = csi_char as u32; + if (0x40..=0x7E).contains(&code) { + break; + } + } + continue; + } + + // OSC sequence: ESC ] ... (BEL or ESC \) + if next == ']' { + chars.next(); + let mut previous_was_escape = false; + for osc_char in chars.by_ref() { + if osc_char == '\u{07}' { + break; + } + + if previous_was_escape && osc_char == '\\' { + break; + } + + previous_was_escape = osc_char == '\u{1B}'; + } + continue; + } + } + + // Unknown escape sequence: ignore the escape char itself. + continue; + } + + if ch.is_control() && ch != '\t' { + continue; + } + + sanitized.push(ch); + } + + sanitized +} + /// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get /// the port where the .NET server should listen to. #[get("/system/dotnet/port")] @@ -111,11 +164,12 @@ pub fn start_dotnet_server() { // NOTE: Log events are sent via structured HTTP API calls. // This loop serves for fundamental output (e.g., startup errors). while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - let line = line.trim_end(); - info!(Source = ".NET Server (stdout)"; "{line}"); + let line = sanitize_stdout_line(line.trim_end()); + if !line.trim().is_empty() { + info!(Source = ".NET Server (stdout)"; "{line}"); + } } }); - } /// This endpoint is called by the .NET server to signal that the server is ready. diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index c5f0a6c7..a1477269 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -15,6 +15,9 @@ pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); /// The config directory where the application stores its configuration. pub static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); +/// The user language cached once per runtime process. +static USER_LANGUAGE: OnceLock = OnceLock::new(); + /// Returns the config directory. #[get("/system/directories/config")] pub fn get_config_directory(_token: APIToken) -> String { @@ -87,12 +90,11 @@ fn normalize_locale_tag(locale: &str) -> Option { } #[cfg(target_os = "linux")] -fn read_locale_from_environment() -> Option { +fn read_locale_from_environment() -> Option<(String, &'static str)> { 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); + return Some((locale, "LANGUAGE")); } } } @@ -100,8 +102,7 @@ fn read_locale_from_environment() -> Option { 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); + return Some((locale, key)); } } } @@ -110,10 +111,35 @@ fn read_locale_from_environment() -> Option { } #[cfg(not(target_os = "linux"))] -fn read_locale_from_environment() -> Option { +fn read_locale_from_environment() -> Option<(String, &'static str)> { None } +enum LanguageDetectionSource { + SysLocale, + LinuxEnvironmentVariable(&'static str), + DefaultLanguage, +} + +fn detect_user_language() -> (String, LanguageDetectionSource) { + if let Some(locale) = get_locale() { + if let Some(normalized_locale) = normalize_locale_tag(&locale) { + return (normalized_locale, LanguageDetectionSource::SysLocale); + } + + warn!("sys-locale returned an unusable locale value: '{}'.", locale); + } + + if let Some((locale, key)) = read_locale_from_environment() { + return (locale, LanguageDetectionSource::LinuxEnvironmentVariable(key)); + } + + ( + String::from(DEFAULT_LANGUAGE), + LanguageDetectionSource::DefaultLanguage, + ) +} + #[cfg(test)] mod tests { use super::normalize_locale_tag; @@ -137,21 +163,32 @@ mod tests { #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { - 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; - } + USER_LANGUAGE + .get_or_init(|| { + let (user_language, source) = detect_user_language(); + match source { + LanguageDetectionSource::SysLocale => { + info!("Detected user language from sys-locale: '{}'.", user_language); + }, - warn!("sys-locale returned an unusable locale value: '{}'.", locale); - } + LanguageDetectionSource::LinuxEnvironmentVariable(key) => { + info!( + "Detected user language from Linux environment variable '{}': '{}'.", + key, user_language + ); + }, - if let Some(locale) = read_locale_from_environment() { - return locale; - } + LanguageDetectionSource::DefaultLanguage => { + warn!( + "Could not determine the system language. Use default '{}'.", + DEFAULT_LANGUAGE + ); + }, + } - warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE); - String::from(DEFAULT_LANGUAGE) + user_language + }) + .clone() } #[get("/system/enterprise/config/id")] From 721d5c9070f4bfd75aeac65751525df1a0efa454 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 26 Feb 2026 08:51:22 +0100 Subject: [PATCH 14/14] Fixed chat issue with HTML code (#679) --- .../Chat/ContentBlockComponent.razor | 4 +- .../Chat/ContentBlockComponent.razor.cs | 108 +++++++++++++++- .../Components/Changelog.razor | 2 +- .../Components/ChatComponent.razor | 1 + .../Components/ConfidenceInfo.razor | 2 +- .../Settings/SettingsPanelProviders.razor | 2 +- .../Dialogs/DocumentCheckDialog.razor | 2 +- .../Dialogs/PandocDialog.razor | 2 +- .../Dialogs/UpdateDialog.razor | 2 +- app/MindWork AI Studio/Pages/Home.razor | 4 +- .../Pages/Information.razor | 4 +- app/MindWork AI Studio/Tools/Markdown.cs | 7 + .../wwwroot/changelog/v26.3.1.md | 4 +- tests/README.md | 16 +++ tests/integration_tests/README.md | 12 ++ .../chat/chat_rendering_regression_tests.md | 120 ++++++++++++++++++ 16 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/integration_tests/README.md create mode 100644 tests/integration_tests/chat/chat_rendering_regression_tests.md diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index 7c09ae78..579e8bf2 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -96,10 +96,10 @@ } else { - + @if (textContent.Sources.Count > 0) { - + } } } diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index e29a016d..29e70487 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -10,6 +10,18 @@ namespace AIStudio.Chat; /// public partial class ContentBlockComponent : MSGComponentBase { + private static readonly string[] HTML_TAG_MARKERS = + [ + " /// The role of the chat content block. /// @@ -68,18 +80,37 @@ public partial class ContentBlockComponent : MSGComponentBase private RustService RustService { get; init; } = null!; private bool HideContent { get; set; } + private bool hasRenderHash; + private int lastRenderHash; #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { - // Register the streaming events: - this.Content.StreamingDone = this.AfterStreaming; - this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged); - + this.RegisterStreamingEvents(); await base.OnInitializedAsync(); } + protected override Task OnParametersSetAsync() + { + this.RegisterStreamingEvents(); + return base.OnParametersSetAsync(); + } + + /// + protected override bool ShouldRender() + { + var currentRenderHash = this.CreateRenderHash(); + if (!this.hasRenderHash || currentRenderHash != this.lastRenderHash) + { + this.lastRenderHash = currentRenderHash; + this.hasRenderHash = true; + return true; + } + + return false; + } + /// /// Gets called when the content stream ended. /// @@ -111,6 +142,47 @@ public partial class ContentBlockComponent : MSGComponentBase }); } + private void RegisterStreamingEvents() + { + this.Content.StreamingDone = this.AfterStreaming; + this.Content.StreamingEvent = () => this.InvokeAsync(this.StateHasChanged); + } + + private int CreateRenderHash() + { + var hash = new HashCode(); + hash.Add(this.Role); + hash.Add(this.Type); + hash.Add(this.Time); + hash.Add(this.Class); + hash.Add(this.IsLastContentBlock); + hash.Add(this.IsSecondToLastBlock); + hash.Add(this.HideContent); + hash.Add(this.SettingsManager.IsDarkMode); + hash.Add(this.RegenerateEnabled()); + hash.Add(this.Content.InitialRemoteWait); + hash.Add(this.Content.IsStreaming); + hash.Add(this.Content.FileAttachments.Count); + hash.Add(this.Content.Sources.Count); + + switch (this.Content) + { + case ContentText text: + var textValue = text.Text; + hash.Add(textValue.Length); + hash.Add(textValue.GetHashCode(StringComparison.Ordinal)); + hash.Add(text.Sources.Count); + break; + + case ContentImage image: + hash.Add(image.SourceType); + hash.Add(image.Source); + break; + } + + return hash.ToHashCode(); + } + #endregion private string CardClasses => $"my-2 rounded-lg {this.Class}"; @@ -121,6 +193,34 @@ public partial class ContentBlockComponent : MSGComponentBase { CodeBlock = { Theme = this.CodeColorPalette }, }; + + private static string NormalizeMarkdownForRendering(string text) + { + var cleaned = text.RemoveThinkTags().Trim(); + if (string.IsNullOrWhiteSpace(cleaned)) + return string.Empty; + + if (cleaned.Contains("```", StringComparison.Ordinal)) + return cleaned; + + if (LooksLikeRawHtml(cleaned)) + return $"```html{Environment.NewLine}{cleaned}{Environment.NewLine}```"; + + return cleaned; + } + + private static bool LooksLikeRawHtml(string text) + { + var content = text.TrimStart(); + if (!content.StartsWith("<", StringComparison.Ordinal)) + return false; + + foreach (var marker in HTML_TAG_MARKERS) + if (content.Contains(marker, StringComparison.OrdinalIgnoreCase)) + return true; + + return content.Contains("", StringComparison.Ordinal); + } private async Task RemoveBlock() { diff --git a/app/MindWork AI Studio/Components/Changelog.razor b/app/MindWork AI Studio/Components/Changelog.razor index 1afebfc3..7ee43021 100644 --- a/app/MindWork AI Studio/Components/Changelog.razor +++ b/app/MindWork AI Studio/Components/Changelog.razor @@ -6,4 +6,4 @@ } - \ No newline at end of file + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 52b82b9b..3c49a4b5 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -16,6 +16,7 @@ @if (!block.HideFromUser) { @T("Description") - + @if (this.currentConfidence.Sources.Count > 0) { diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index f6704dc5..8a862702 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -104,7 +104,7 @@ @context.ToName() - + diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor index f3b75837..8936e04e 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor @@ -54,7 +54,7 @@ Class="ma-2 pe-4" HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
- +
diff --git a/app/MindWork AI Studio/Dialogs/PandocDialog.razor b/app/MindWork AI Studio/Dialogs/PandocDialog.razor index 2914b38e..c4f2ac3e 100644 --- a/app/MindWork AI Studio/Dialogs/PandocDialog.razor +++ b/app/MindWork AI Studio/Dialogs/PandocDialog.razor @@ -30,7 +30,7 @@ } else if (!string.IsNullOrWhiteSpace(this.licenseText)) { - + } diff --git a/app/MindWork AI Studio/Dialogs/UpdateDialog.razor b/app/MindWork AI Studio/Dialogs/UpdateDialog.razor index 62f3dd7a..f5345523 100644 --- a/app/MindWork AI Studio/Dialogs/UpdateDialog.razor +++ b/app/MindWork AI Studio/Dialogs/UpdateDialog.razor @@ -5,7 +5,7 @@ @this.HeaderText - + diff --git a/app/MindWork AI Studio/Pages/Home.razor b/app/MindWork AI Studio/Pages/Home.razor index 53d48e6e..eae947ab 100644 --- a/app/MindWork AI Studio/Pages/Home.razor +++ b/app/MindWork AI Studio/Pages/Home.razor @@ -27,7 +27,7 @@ - + @@ -35,7 +35,7 @@ - + diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 435a6a56..a859a142 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -297,8 +297,8 @@ - + - + \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Markdown.cs b/app/MindWork AI Studio/Tools/Markdown.cs index 0ecf3774..49a2309c 100644 --- a/app/MindWork AI Studio/Tools/Markdown.cs +++ b/app/MindWork AI Studio/Tools/Markdown.cs @@ -1,7 +1,14 @@ +using Markdig; + namespace AIStudio.Tools; public static class Markdown { + public static readonly MarkdownPipeline SAFE_MARKDOWN_PIPELINE = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .DisableHtml() + .Build(); + public static MudMarkdownProps DefaultConfig => new() { Heading = diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index 840e2947..f5bd763b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -1,5 +1,7 @@ # v26.3.1, build 235 (2026-03-xx xx:xx UTC) - Improved the performance by caching the OS language detection and requesting the user language only once per app start. +- Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations. - Improved the user-language logging by limiting language detection logs to a single entry per app start. - Improved the logbook readability by removing non-readable special characters from log entries. -- Improved the logbook reliability by significantly reducing duplicate log entries. \ No newline at end of file +- Improved the logbook reliability by significantly reducing duplicate log entries. +- Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown. Thanks Inga for reporting this issue and providing some context on how to reproduce it. \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..1856f217 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,16 @@ +# Test Documentation + +This directory stores manual and automated test definitions for MindWork AI Studio. + +## Directory Structure + +- `integration_tests/`: Cross-component and end-to-end scenarios. + +## Authoring Rules + +- Use US English. +- Keep each feature area in its own Markdown file. +- Prefer stable test IDs (for example: `TC-CHAT-001`). +- Record expected behavior for: + - known vulnerable baseline builds (if relevant), + - current fixed builds. diff --git a/tests/integration_tests/README.md b/tests/integration_tests/README.md new file mode 100644 index 00000000..aa23175e --- /dev/null +++ b/tests/integration_tests/README.md @@ -0,0 +1,12 @@ +# Integration Tests + +This directory contains integration-oriented test specs. + +## Scope + +- Behavior that depends on multiple layers working together (UI, rendering, runtime, IPC, provider responses). +- Regressions that are hard to catch with unit tests only. + +## Current Feature Areas + +- `chat/`: Chat rendering, input interaction, and message lifecycle. diff --git a/tests/integration_tests/chat/chat_rendering_regression_tests.md b/tests/integration_tests/chat/chat_rendering_regression_tests.md new file mode 100644 index 00000000..ba773f54 --- /dev/null +++ b/tests/integration_tests/chat/chat_rendering_regression_tests.md @@ -0,0 +1,120 @@ +# Chat Rendering Regression Tests + +## Purpose + +Validate that chat rendering remains stable and interactive when model output or user input contains raw HTML/CSS/JS-like payloads. + +## Test Type + +Manual regression and integration checks. + +## Preconditions + +1. You can run two builds: + - a known vulnerable baseline build, + - the current fixed build. +2. At least one provider is configured and can answer prompts. +3. Open the Chat page. + +## Execution Flow (for each test case) + +1. Copy the test prompt exactly into the user prompt field. +2. Send the prompt. +3. Observe behavior immediately after send. +4. If the UI is still visible, type additional text in the prompt input. +5. Repeat on both builds. + +## Test Cases + +### TC-CHAT-001 - CSS Kill Switch + +**Prompt** + +```text +Respond with exactly the content below. No explanations, no Markdown code fences, no backticks. + + +
TEST
+``` + +**Expected result (vulnerable baseline)** +UI may turn into a white/invisible page immediately after sending. + +**Expected result (fixed build)** +Chat stays visible and usable. Content is rendered as inert text/code, not active page styling. + +--- + +### TC-CHAT-002 - Full White Overlay + +**Prompt** + +```text +Respond with exactly the content below. No explanations, no Markdown code fences, no backticks. + + +
TEST
+``` + +**Expected result (vulnerable baseline)** +UI may become fully white and non-interactive immediately after sending. + +**Expected result (fixed build)** +No overlay takes over the app. Chat remains interactive. + +--- + +### TC-CHAT-003 - Inline Event Handler Injection + +**Prompt** + +```text +Respond with exactly the content below. No explanations, no Markdown code fences, no backticks. + + +
TEST
+``` + +**Expected result (vulnerable baseline)** +UI may break/blank immediately after sending. + +**Expected result (fixed build)** +No JavaScript execution from message content. Chat remains stable. + +--- + +### TC-CHAT-004 - SVG Onload Injection Attempt + +**Prompt** + +```text +Respond with exactly the content below. No explanations, no Markdown code fences, no backticks. + + +
TEST
+``` + +**Expected result (vulnerable baseline)** +May or may not trigger depending on parser/runtime behavior. + +**Expected result (fixed build)** +No script-like execution from content. Chat remains stable and interactive. + +## Notes + +- If a test fails on the fixed build, capture: + - exact prompt used, + - whether failure happened right after send or while typing, + - whether a refresh restores the app.