From 0f6a8c324779ec2c9e77bca56370a9af3e413cc3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 29 Jan 2026 07:26:36 +0100 Subject: [PATCH 01/34] Fixed provider filtering for assistants (#646) --- .../Assistants/AssistantBase.razor | 4 ++- .../Components/ProviderSelection.razor.cs | 30 ++++++++++++++----- .../wwwroot/changelog/v26.2.1.md | 1 + 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index d9bd21fb..fa0fc2a9 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -22,7 +22,9 @@ @if (this.Body is not null) { - @this.Body + + @this.Body + diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index ded13ebd..bdc76ddc 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; -using AIStudio.Assistants; using AIStudio.Provider; using Microsoft.AspNetCore.Components; @@ -10,7 +10,7 @@ namespace AIStudio.Components; public partial class ProviderSelection : MSGComponentBase { [CascadingParameter] - public AssistantBase? AssistantBase { get; set; } + public Tools.Components? Component { get; set; } [Parameter] public AIStudio.Settings.Provider ProviderSettings { get; set; } = AIStudio.Settings.Provider.NONE; @@ -21,6 +21,9 @@ public partial class ProviderSelection : MSGComponentBase [Parameter] public Func ValidateProvider { get; set; } = _ => null; + [Inject] + private ILogger Logger { get; init; } = null!; + private async Task SelectionChanged(AIStudio.Settings.Provider provider) { this.ProviderSettings = provider; @@ -30,10 +33,23 @@ public partial class ProviderSelection : MSGComponentBase [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] private IEnumerable GetAvailableProviders() { - var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(this.AssistantBase?.Component ?? Tools.Components.NONE); - foreach (var provider in this.SettingsManager.ConfigurationData.Providers) - if (provider.UsedLLMProvider != LLMProviders.NONE) - if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) - yield return provider; + switch (this.Component) + { + case null: + this.Logger.LogError("Component is null! Cannot filter providers based on component settings. Missed CascadingParameter?"); + yield break; + + case Tools.Components.NONE: + this.Logger.LogError("Component is NONE! Cannot filter providers based on component settings. Used wrong component?"); + yield break; + + case { } component: + var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(component); + foreach (var provider in this.SettingsManager.ConfigurationData.Providers) + if (provider.UsedLLMProvider != LLMProviders.NONE) + if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) + yield return provider; + break; + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index da549883..13dde977 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -1 +1,2 @@ # v26.2.1, build 233 (2026-02-xx xx:xx UTC) +- Fixed a bug where the global minimum confidence level was not being applied to the assistants. \ No newline at end of file From 3d9e8a6f48ab989a032ac4dcd5d13a7b5612f06a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 14:50:19 +0100 Subject: [PATCH 02/34] Added missing document analysis assistant features (#644) --- .../Agenda/AssistantAgenda.razor.cs | 2 +- .../Assistants/AssistantBase.razor | 7 +- .../Assistants/AssistantBase.razor.cs | 33 +- .../BiasDay/BiasOfTheDayAssistant.razor.cs | 2 +- .../Coding/AssistantCoding.razor.cs | 2 +- .../DocumentAnalysisAssistant.razor | 196 ++++++---- .../DocumentAnalysisAssistant.razor.cs | 344 ++++++++++++++++-- .../Assistants/EMail/AssistantEMail.razor.cs | 2 +- .../Assistants/ERI/AssistantERI.razor.cs | 4 +- .../AssistantGrammarSpelling.razor.cs | 2 +- .../Assistants/I18N/AssistantI18N.razor.cs | 2 +- .../Assistants/I18N/allTexts.lua | 51 ++- .../IconFinder/AssistantIconFinder.razor.cs | 2 +- .../JobPosting/AssistantJobPostings.razor.cs | 2 +- .../LegalCheck/AssistantLegalCheck.razor.cs | 2 +- .../MyTasks/AssistantMyTasks.razor.cs | 2 +- .../AssistantRewriteImprove.razor.cs | 2 +- .../Synonym/AssistantSynonyms.razor.cs | 2 +- .../AssistantTextSummarizer.razor.cs | 2 +- .../Translation/AssistantTranslation.razor.cs | 2 +- .../Components/AssistantBlock.razor | 7 +- .../Components/AssistantBlock.razor.cs | 8 +- .../ConfigurationMinConfidenceSelection.razor | 2 +- ...nfigurationMinConfidenceSelection.razor.cs | 6 + .../ConfigurationProviderSelection.razor.cs | 12 +- .../Components/ConfigurationSelect.razor.cs | 7 + .../Components/ProfileFormSelection.razor | 6 +- .../Components/ProfileFormSelection.razor.cs | 3 + .../Components/ProviderSelection.razor.cs | 12 +- .../Components/ReadFileContent.razor | 4 +- .../Components/ReadFileContent.razor.cs | 8 +- .../Dialogs/Settings/NoSettingsPanel.razor | 2 + .../SettingsDialogDocumentAnalysis.razor | 28 -- .../SettingsDialogDocumentAnalysis.razor.cs | 3 - app/MindWork AI Studio/Pages/Assistants.razor | 2 +- .../Plugins/configuration/plugin.lua | 175 +++++---- .../plugin.lua | 51 ++- .../plugin.lua | 51 ++- .../Settings/ChatTemplate.cs | 2 +- .../Settings/DataModel/Data.cs | 5 + .../DataModel/DataDocumentAnalysisPolicy.cs | 118 +++++- .../Settings/EmbeddingProvider.cs | 2 +- app/MindWork AI Studio/Settings/Profile.cs | 2 +- app/MindWork AI Studio/Settings/Provider.cs | 2 +- .../Settings/TranscriptionProvider.cs | 2 +- .../Tools/ComponentsExtensions.cs | 17 +- .../Tools/PluginSystem/PluginConfiguration.cs | 3 + .../PluginSystem/PluginConfigurationObject.cs | 2 + .../PluginConfigurationObjectType.cs | 1 + .../PluginSystem/PluginFactory.Loading.cs | 4 + .../wwwroot/changelog/v26.2.1.md | 4 + 51 files changed, 861 insertions(+), 353 deletions(-) create mode 100644 app/MindWork AI Studio/Dialogs/Settings/NoSettingsPanel.razor delete mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor delete mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor.cs diff --git a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs index c0571c7c..4658a16b 100644 --- a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs +++ b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs @@ -7,7 +7,7 @@ namespace AIStudio.Assistants.Agenda; public partial class AssistantAgenda : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.AGENDA_ASSISTANT; + protected override Tools.Components Component => Tools.Components.AGENDA_ASSISTANT; protected override string Title => T("Agenda Planner"); diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index fa0fc2a9..5fee5f0a 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -9,7 +9,10 @@ @this.Title - + @if (this.HasSettingsPanel) + { + + } @@ -147,4 +150,4 @@ - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index b260b8ed..a91f2b57 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -1,6 +1,7 @@ using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; +using AIStudio.Dialogs.Settings; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -41,8 +42,8 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected abstract string Description { get; } protected abstract string SystemPrompt { get; } - - public abstract Tools.Components Component { get; } + + protected abstract Tools.Components Component { get; } protected virtual Func Result2Copy => () => this.resultingContentBlock is null ? string.Empty : this.resultingContentBlock.Content switch { @@ -81,6 +82,8 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected virtual ChatThread ConvertToChatThread => this.chatThread ?? new(); protected virtual IReadOnlyList FooterButtons => []; + + protected virtual bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel); protected AIStudio.Settings.Provider providerSettings = Settings.Provider.NONE; protected MudForm? form; @@ -185,6 +188,16 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.inputIsValid = false; this.StateHasChanged(); } + + /// + /// Clear all input issues. + /// + protected void ClearInputIssues() + { + this.inputIssues = []; + this.inputIsValid = true; + this.StateHasChanged(); + } protected void CreateChatThread() { @@ -218,6 +231,13 @@ public abstract partial class AssistantBase : AssistantLowerBase wher return chatId; } + + protected virtual void ResetProviderAndProfileSelection() + { + this.providerSettings = this.SettingsManager.GetPreselectedProvider(this.Component); + this.currentProfile = this.SettingsManager.GetPreselectedProfile(this.Component); + this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(this.Component); + } protected DateTimeOffset AddUserRequest(string request, bool hideContentFromUser = false, params List attachments) { @@ -310,6 +330,9 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected async Task OpenSettingsDialog() { + if (!this.HasSettingsPanel) + return; + var dialogParameters = new DialogParameters(); await this.DialogService.ShowAsync(null, dialogParameters, DialogOptions.FULLSCREEN); } @@ -356,9 +379,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher await this.JsRuntime.ClearDiv(AFTER_RESULT_DIV_ID); this.ResetForm(); - this.providerSettings = this.SettingsManager.GetPreselectedProvider(this.Component); - this.currentProfile = this.SettingsManager.GetPreselectedProfile(this.Component); - this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(this.Component); + this.ResetProviderAndProfileSelection(); this.inputIsValid = false; this.inputIssues = []; @@ -398,4 +419,4 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs b/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs index d87701a3..bf28b7c4 100644 --- a/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs @@ -8,7 +8,7 @@ namespace AIStudio.Assistants.BiasDay; public partial class BiasOfTheDayAssistant : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.BIAS_DAY_ASSISTANT; + protected override Tools.Components Component => Tools.Components.BIAS_DAY_ASSISTANT; protected override string Title => T("Bias of the Day"); diff --git a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs index 7bf8e932..c96043ab 100644 --- a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs +++ b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs @@ -6,7 +6,7 @@ namespace AIStudio.Assistants.Coding; public partial class AssistantCoding : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.CODING_ASSISTANT; + protected override Tools.Components Component => Tools.Components.CODING_ASSISTANT; protected override string Title => T("Coding Assistant"); diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index 576664bb..93fc8846 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -1,6 +1,5 @@ @attribute [Route(Routes.ASSISTANT_DOCUMENT_ANALYSIS)] -@inherits AssistantBaseCore - +@inherits AssistantBaseCore @using AIStudio.Settings.DataModel @@ -25,9 +24,21 @@ else @foreach (var policy in this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies) { - - @policy.PolicyName - + @if (policy.IsEnterpriseConfiguration) + { + + @policy.PolicyName + + + + + } + else + { + + @policy.PolicyName + + } } } @@ -36,85 +47,124 @@ else @T("Add policy") - + @T("Delete this policy") - - - @if (!this.policyDefinitionExpanded) - { - - @T("Expand this section to view and edit the policy definition.") - - } - else - { - - @T("Common settings") - - - - - - - + - - @T("Analysis and output rules") - - - - @T("Use the analysis and output rules to define how the AI evaluates your documents and formats the results.") - - - - @T("The analysis rules specify what the AI should pay particular attention to while reviewing the documents you provide, and which aspects it should highlight or save. For example, if you want to extract the potential of green hydrogen for agriculture from a variety of general publications, you can explicitly define this in the analysis rules.") - - - - - - - - @T("After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points.") - - - - - - - - @T("Preparation for enterprise distribution") - - - - - @T("Export policy as configuration section") - - - } - - - - - - +@if ((this.selectedPolicy?.HidePolicyDefinition ?? false) && (this.selectedPolicy?.IsEnterpriseConfiguration ?? false)) +{ + @* When HidePolicyDefinition is true AND the policy is an enterprise configuration, show only the document selection section without expansion panels *@ +
+ + @T("Document selection - Policy"): @this.selectedPolicy?.PolicyName + + + @T("Policy Description") - + @this.selectedPolicy?.PolicyDescription - - + + @T("Documents for the analysis") - + - - - +
+} +else +{ + @* Standard view with expansion panels *@ + + + @if (!this.policyDefinitionExpanded) + { + + @T("Expand this section to view and edit the policy definition.") + + } + else + { + + @T("Common settings") + + + + + + + + + + @T("Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create.") + + + + + + + + + + + + @T("Analysis and output rules") + + + + @T("Use the analysis and output rules to define how the AI evaluates your documents and formats the results.") + + + + @T("The analysis rules specify what the AI should pay particular attention to while reviewing the documents you provide, and which aspects it should highlight or save. For example, if you want to extract the potential of green hydrogen for agriculture from a variety of general publications, you can explicitly define this in the analysis rules.") + + + + + + + + @T("After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points.") + + + + + + + + @T("Preparation for enterprise distribution") + + + + @T("Export policy as configuration section") + + } + + + + + + + @T("Policy Description") + + + + @this.selectedPolicy?.PolicyDescription + + + + @T("Documents for the analysis") + + + + + + +} - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 7604e071..f58e6619 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -1,8 +1,11 @@ using System.Text; +using System.Diagnostics.CodeAnalysis; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Dialogs.Settings; +using AIStudio.Provider; +using AIStudio.Settings; using AIStudio.Settings.DataModel; using Microsoft.AspNetCore.Components; @@ -11,12 +14,12 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Assistants.DocumentAnalysis; -public partial class DocumentAnalysisAssistant : AssistantBaseCore +public partial class DocumentAnalysisAssistant : AssistantBaseCore { [Inject] private IDialogService DialogService { get; init; } = null!; - - public override Tools.Components Component => Tools.Components.DOCUMENT_ANALYSIS_ASSISTANT; + + protected override Tools.Components Component => Tools.Components.DOCUMENT_ANALYSIS_ASSISTANT; protected override string Title => T("Document Analysis Assistant"); @@ -116,7 +119,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore SubmitAction => this.Analyze; - protected override bool SubmitDisabled => (this.IsNoPolicySelected || this.loadedDocumentPaths.Count==0); + protected override bool SubmitDisabled => this.IsNoPolicySelected || this.loadedDocumentPaths.Count == 0; protected override ChatThread ConvertToChatThread { @@ -162,15 +165,31 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore(Event.SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT).FirstOrDefault(); - if (receivedDeferredContent is not null) - this.deferredContent = receivedDeferredContent; - await base.OnInitializedAsync(); + this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED ]); + this.UpdateProviders(); + this.ApplyPolicyPreselection(preferPolicyPreselection: true); } #endregion @@ -219,34 +240,43 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore loadedDocumentPaths = []; + private readonly List> availableLLMProviders = new(); private bool IsNoPolicySelectedOrProtected => this.selectedPolicy is null || this.selectedPolicy.IsProtected; @@ -257,6 +287,10 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore(provider.InstanceName, provider.Id)); + } + private async Task RemovePolicy() { if(this.selectedPolicy is null) @@ -283,6 +327,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore { { x => x.Message, string.Format(T("Are you sure you want to delete the document analysis policy '{0}'?"), this.selectedPolicy.PolicyName) }, @@ -316,6 +363,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore= minimumLevel) + { + this.currentProfile = this.ResolveProfileSelection(); + return; + } + } + + // Try to apply the policy preselection: + var policyProvider = this.SettingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == this.selectedPolicy.PreselectedProvider); + if (policyProvider is not null && policyProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) + { + this.providerSettings = policyProvider; + this.currentProfile = this.ResolveProfileSelection(); + return; + } + + var fallbackProvider = this.SettingsManager.GetPreselectedProvider(this.Component, this.providerSettings.Id); + if (fallbackProvider != Settings.Provider.NONE && + fallbackProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level < minimumLevel) + fallbackProvider = Settings.Provider.NONE; + + this.providerSettings = fallbackProvider; + this.currentProfile = this.ResolveProfileSelection(); + } + + private ConfidenceLevel GetPolicyMinimumConfidenceLevel() + { + var minimumLevel = ConfidenceLevel.NONE; + var llmSettings = this.SettingsManager.ConfigurationData.LLMProviders; + var enforceGlobalMinimumConfidence = llmSettings is { EnforceGlobalMinimumConfidence: true, GlobalMinimumConfidence: not ConfidenceLevel.NONE and not ConfidenceLevel.UNKNOWN }; + if (enforceGlobalMinimumConfidence) + minimumLevel = llmSettings.GlobalMinimumConfidence; + + if (this.selectedPolicy is not null && this.selectedPolicy.MinimumProviderConfidence > minimumLevel) + minimumLevel = this.selectedPolicy.MinimumProviderConfidence; + + return minimumLevel; + } + + private Profile ResolveProfileSelection() + { + if (this.selectedPolicy is not null && !string.IsNullOrWhiteSpace(this.selectedPolicy.PreselectedProfile)) + { + var policyProfile = this.SettingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == this.selectedPolicy.PreselectedProfile); + if (policyProfile is not null) + return policyProfile; + } + + return this.SettingsManager.GetPreselectedProfile(this.Component); + } + + private async Task PolicyMinimumConfidenceWasChangedAsync(ConfidenceLevel level) + { + this.policyMinimumProviderConfidence = level; + await this.AutoSave(); + + this.ApplyPolicyPreselection(); + } + + private void PolicyPreselectedProviderWasChanged(string providerId) + { + if (this.selectedPolicy is null) + return; + + this.policyPreselectedProviderId = providerId; + this.selectedPolicy.PreselectedProvider = providerId; + this.providerSettings = Settings.Provider.NONE; + this.ApplyPolicyPreselection(); + } + + private async Task PolicyPreselectedProfileWasChangedAsync(Profile profile) + { + this.policyPreselectedProfileId = profile.Id; + if (this.selectedPolicy is not null) + this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfileId; + + this.currentProfile = this.ResolveProfileSelection(); + await this.AutoSave(); + } + + #region Overrides of MSGComponentBase + + protected override Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + case Event.CONFIGURATION_CHANGED: + this.UpdateProviders(); + this.StateHasChanged(); + break; + + case Event.PLUGINS_RELOADED: + this.HandlePluginsReloaded(); + this.StateHasChanged(); + break; + } + + return Task.CompletedTask; + } + + #endregion + + private void HandlePluginsReloaded() + { + // Check if the currently selected policy still exists after plugin reload: + if (this.selectedPolicy is not null) + { + var stillExists = this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies + .Any(p => p.Id == this.selectedPolicy.Id); + + if (!stillExists) + { + // Policy was removed, select a new one: + this.selectedPolicy = this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies.FirstOrDefault(); + } + else + { + // Policy still exists, update the reference to the potentially updated version: + this.selectedPolicy = this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies + .First(p => p.Id == this.selectedPolicy.Id); + } + } + else + { + // No policy was selected, select the first one if available: + this.selectedPolicy = this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies.FirstOrDefault(); + } + + // Update form values to reflect the current policy: + this.ResetForm(); + + // Update the expansion state based on the policy protection: + this.policyDefinitionExpanded = !this.selectedPolicy?.IsProtected ?? true; + + // Update available providers: + this.UpdateProviders(); + + // Apply policy preselection: + this.ApplyPolicyPreselection(preferPolicyPreselection: true); + + // Reset validation state: + this.form?.ResetValidation(); + this.ClearInputIssues(); + } private string? ValidatePolicyName(string name) { + if(this.selectedPolicy?.IsEnterpriseConfiguration == true) + return null; + if(string.IsNullOrWhiteSpace(name)) return T("Please provide a name for your policy. This name will be used to identify the policy in AI Studio."); @@ -346,6 +573,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.EMAIL_ASSISTANT; + protected override Tools.Components Component => Tools.Components.EMAIL_ASSISTANT; protected override string Title => T("E-Mail"); diff --git a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs index 6268b137..d8866cfe 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs +++ b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs @@ -19,8 +19,8 @@ public partial class AssistantERI : AssistantBaseCore [Inject] private IDialogService DialogService { get; init; } = null!; - - public override Tools.Components Component => Tools.Components.ERI_ASSISTANT; + + protected override Tools.Components Component => Tools.Components.ERI_ASSISTANT; protected override string Title => T("ERI Server"); diff --git a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs index 6025f133..64168fd2 100644 --- a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs +++ b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.GrammarSpelling; public partial class AssistantGrammarSpelling : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.GRAMMAR_SPELLING_ASSISTANT; + protected override Tools.Components Component => Tools.Components.GRAMMAR_SPELLING_ASSISTANT; protected override string Title => T("Grammar & Spelling Checker"); diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs index a00ff8b4..d229eb9b 100644 --- a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs @@ -16,7 +16,7 @@ namespace AIStudio.Assistants.I18N; public partial class AssistantI18N : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.I18N_ASSISTANT; + protected override Tools.Components Component => Tools.Components.I18N_ASSISTANT; protected override string Title => T("Localization"); diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 2196497c..a3f56347 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -397,9 +397,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description of your analysis rules. This rules will be used to instruct the AI on how to analyze the documents. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1291179736"] = "Please provide a description of your analysis rules. This rules will be used to instruct the AI on how to analyze the documents." --- Not implemented yet. -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1568777658"] = "Not implemented yet." - -- Yes, protect this policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1762380857"] = "Yes, protect this policy" @@ -409,9 +406,18 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description for your policy. This description will be used to inform users about the purpose of your document analysis policy. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1837166236"] = "Please provide a description for your policy. This description will be used to inform users about the purpose of your document analysis policy." +-- Hide the policy definition when distributed via configuration plugin? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1875622568"] = "Hide the policy definition when distributed via configuration plugin?" + -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Common settings" +-- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create." + +-- This policy is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2035084381"] = "This policy is managed by your organization." + -- The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T206207667"] = "The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted." @@ -451,6 +457,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy name UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2879019438"] = "Policy name" +-- No policy is selected. Please select a policy to export. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2929693091"] = "No policy is selected. Please select a policy to export." + -- Policy Description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3023558273"] = "Policy Description" @@ -466,6 +475,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3157740273"] = "Policy {0}" +-- No, show the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3166091879"] = "No, show the policy definition" + -- The description of your policy must be between 32 and 512 characters long. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3285636934"] = "The description of your policy must be between 32 and 512 characters long." @@ -508,6 +520,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T726434276"] = "After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points." +-- The selected policy contains invalid data. Please fix the issues before exporting the policy. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T736334861"] = "The selected policy contains invalid data. Please fix the issues before exporting the policy." + -- Policy description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T748735777"] = "Policy description" @@ -517,6 +532,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Here you have the option to save different policies for various document analysis assistants and switch between them. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T848153710"] = "Here you have the option to save different policies for various document analysis assistants and switch between them." +-- Yes, hide the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T940701960"] = "Yes, hide the policy definition" + -- Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T1143222914"] = "Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input." @@ -4027,33 +4045,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473 -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Local Directory" --- Assistant: Document Analysis -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1372406750"] = "Assistant: Document Analysis" - --- Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1870328357"] = "Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function." - --- Would you like to preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2221665527"] = "Would you like to preselect one of your profiles?" - --- Preselect document analysis options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2230062650"] = "Preselect document analysis options?" - --- When enabled, you can preselect some document analysis options. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2301091111"] = "When enabled, you can preselect some document analysis options." - --- No document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3317802895"] = "No document analysis options are preselected" - --- Close -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3448155331"] = "Close" - --- Document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3945756386"] = "Document analysis options are preselected" - --- Preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T4004501229"] = "Preselect one of your profiles?" - -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "When enabled, you can preselect some ERI server options." diff --git a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs index 08d616d8..294cdd3a 100644 --- a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs +++ b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs @@ -4,7 +4,7 @@ namespace AIStudio.Assistants.IconFinder; public partial class AssistantIconFinder : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.ICON_FINDER_ASSISTANT; + protected override Tools.Components Component => Tools.Components.ICON_FINDER_ASSISTANT; protected override string Title => T("Icon Finder"); diff --git a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs index 21b183f0..d13c2f6d 100644 --- a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs +++ b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.JobPosting; public partial class AssistantJobPostings : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.JOB_POSTING_ASSISTANT; + protected override Tools.Components Component => Tools.Components.JOB_POSTING_ASSISTANT; protected override string Title => T("Job Posting"); diff --git a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs index c0b502ee..100c3df4 100644 --- a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs +++ b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.LegalCheck; public partial class AssistantLegalCheck : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.LEGAL_CHECK_ASSISTANT; + protected override Tools.Components Component => Tools.Components.LEGAL_CHECK_ASSISTANT; protected override string Title => T("Legal Check"); diff --git a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs index fa5d1e27..c93246a8 100644 --- a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs +++ b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs @@ -6,7 +6,7 @@ namespace AIStudio.Assistants.MyTasks; public partial class AssistantMyTasks : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.MY_TASKS_ASSISTANT; + protected override Tools.Components Component => Tools.Components.MY_TASKS_ASSISTANT; protected override string Title => T("My Tasks"); diff --git a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs index 44aa94d2..2ddac0fd 100644 --- a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs +++ b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.RewriteImprove; public partial class AssistantRewriteImprove : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.REWRITE_ASSISTANT; + protected override Tools.Components Component => Tools.Components.REWRITE_ASSISTANT; protected override string Title => T("Rewrite & Improve Text"); diff --git a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs index 3581a5d3..739a4a2f 100644 --- a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs +++ b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.Synonym; public partial class AssistantSynonyms : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.SYNONYMS_ASSISTANT; + protected override Tools.Components Component => Tools.Components.SYNONYMS_ASSISTANT; protected override string Title => T("Synonyms"); diff --git a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs index 257ff39c..35dee799 100644 --- a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.TextSummarizer; public partial class AssistantTextSummarizer : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.TEXT_SUMMARIZER_ASSISTANT; + protected override Tools.Components Component => Tools.Components.TEXT_SUMMARIZER_ASSISTANT; protected override string Title => T("Text Summarizer"); diff --git a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs index 51359e40..6b890ee5 100644 --- a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs +++ b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs @@ -5,7 +5,7 @@ namespace AIStudio.Assistants.Translation; public partial class AssistantTranslation : AssistantBaseCore { - public override Tools.Components Component => Tools.Components.TRANSLATION_ASSISTANT; + protected override Tools.Components Component => Tools.Components.TRANSLATION_ASSISTANT; protected override string Title => T("Translation"); diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor b/app/MindWork AI Studio/Components/AssistantBlock.razor index 372fae7e..8af43e72 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor @@ -26,8 +26,11 @@ @this.ButtonText - + @if (this.HasSettingsPanel) + { + + } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index 69dfe49b..09f0d73d 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Settings.DataModel; +using AIStudio.Dialogs.Settings; using Microsoft.AspNetCore.Components; @@ -37,6 +38,9 @@ public partial class AssistantBlock : MSGComponentBase where TSetting private async Task OpenSettingsDialog() { + if (!this.HasSettingsPanel) + return; + var dialogParameters = new DialogParameters(); await this.DialogService.ShowAsync(T("Open Settings"), dialogParameters, DialogOptions.FULLSCREEN); @@ -51,4 +55,6 @@ public partial class AssistantBlock : MSGComponentBase where TSetting private string BlockStyle => $"border-width: 2px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;"; private bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature); -} \ No newline at end of file + + private bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel); +} diff --git a/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor b/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor index 93a47e8b..01cf6850 100644 --- a/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor +++ b/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor @@ -1,3 +1,3 @@ @using AIStudio.Settings @inherits MSGComponentBase - \ No newline at end of file + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor.cs b/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor.cs index b5d130c8..c980d457 100644 --- a/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationMinConfidenceSelection.razor.cs @@ -17,6 +17,12 @@ public partial class ConfigurationMinConfidenceSelection : MSGComponentBase /// [Parameter] public Action SelectionUpdate { get; set; } = _ => { }; + + /// + /// An asynchronous action that is called when the selection changes. + /// + [Parameter] + public Func SelectionUpdateAsync { get; set; } = _ => Task.CompletedTask; /// /// Boolean value indicating whether the selection is restricted to a global minimum confidence level. diff --git a/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs index 39ae76be..8267219c 100644 --- a/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs @@ -25,6 +25,9 @@ public partial class ConfigurationProviderSelection : MSGComponentBase [Parameter] public Tools.Components Component { get; set; } = Tools.Components.NONE; + + [Parameter] + public ConfidenceLevel ExplicitMinimumConfidence { get; set; } = ConfidenceLevel.UNKNOWN; [Parameter] public Func Disabled { get; set; } = () => false; @@ -38,7 +41,14 @@ public partial class ConfigurationProviderSelection : MSGComponentBase if(this.Component is not Tools.Components.NONE and not Tools.Components.APP_SETTINGS) yield return new(T("Use app default"), string.Empty); + // Get the minimum confidence level for this component, and/or the enforced global minimum confidence level: var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(this.Component); + + // Apply the explicit minimum confidence level if set and higher than the current minimum level: + if (this.ExplicitMinimumConfidence is not ConfidenceLevel.UNKNOWN && this.ExplicitMinimumConfidence > minimumLevel) + minimumLevel = this.ExplicitMinimumConfidence; + + // Filter the providers based on the minimum confidence level: foreach (var providerId in this.Data) { var provider = this.SettingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == providerId.Value); @@ -75,4 +85,4 @@ public partial class ConfigurationProviderSelection : MSGComponentBase } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/ConfigurationSelect.razor.cs b/app/MindWork AI Studio/Components/ConfigurationSelect.razor.cs index e5780c9d..820a4ee0 100644 --- a/app/MindWork AI Studio/Components/ConfigurationSelect.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationSelect.razor.cs @@ -27,6 +27,12 @@ public partial class ConfigurationSelect : ConfigurationBaseCore /// [Parameter] public Action SelectionUpdate { get; set; } = _ => { }; + + /// + /// An asynchronous action that is called when the selection changes. + /// + [Parameter] + public Func SelectionUpdateAsync { get; set; } = _ => Task.CompletedTask; #region Overrides of ConfigurationBase @@ -44,6 +50,7 @@ public partial class ConfigurationSelect : ConfigurationBaseCore private async Task OptionChanged(TConfig updatedValue) { this.SelectionUpdate(updatedValue); + await this.SelectionUpdateAsync(updatedValue); await this.SettingsManager.StoreSettings(); await this.InformAboutChange(); } diff --git a/app/MindWork AI Studio/Components/ProfileFormSelection.razor b/app/MindWork AI Studio/Components/ProfileFormSelection.razor index f963ced7..cd063d41 100644 --- a/app/MindWork AI Studio/Components/ProfileFormSelection.razor +++ b/app/MindWork AI Studio/Components/ProfileFormSelection.razor @@ -2,7 +2,7 @@ @inherits MSGComponentBase - + @foreach (var profile in this.SettingsManager.ConfigurationData.Profiles.GetAllProfiles()) { @@ -11,5 +11,5 @@ } - - \ No newline at end of file + + diff --git a/app/MindWork AI Studio/Components/ProfileFormSelection.razor.cs b/app/MindWork AI Studio/Components/ProfileFormSelection.razor.cs index ec71737e..3dc80979 100644 --- a/app/MindWork AI Studio/Components/ProfileFormSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProfileFormSelection.razor.cs @@ -17,6 +17,9 @@ public partial class ProfileFormSelection : MSGComponentBase [Parameter] public Func Validation { get; set; } = _ => null; + + [Parameter] + public bool Disabled { get; set; } [Inject] public IDialogService DialogService { get; init; } = null!; diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index bdc76ddc..809ed089 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; using AIStudio.Provider; @@ -20,6 +19,9 @@ public partial class ProviderSelection : MSGComponentBase [Parameter] public Func ValidateProvider { get; set; } = _ => null; + + [Parameter] + public ConfidenceLevel ExplicitMinimumConfidence { get; set; } = ConfidenceLevel.UNKNOWN; [Inject] private ILogger Logger { get; init; } = null!; @@ -44,7 +46,15 @@ public partial class ProviderSelection : MSGComponentBase yield break; case { } component: + + // Get the minimum confidence level for this component, and/or the global minimum if enforced: var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(component); + + // Override with the explicit minimum level if set and higher: + if (this.ExplicitMinimumConfidence is not ConfidenceLevel.UNKNOWN && this.ExplicitMinimumConfidence > minimumLevel) + minimumLevel = this.ExplicitMinimumConfidence; + + // Filter providers based on the minimum confidence level: foreach (var provider in this.SettingsManager.ConfigurationData.Providers) if (provider.UsedLLMProvider != LLMProviders.NONE) if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel) diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor b/app/MindWork AI Studio/Components/ReadFileContent.razor index 05a63722..302224de 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor @@ -1,5 +1,5 @@ @inherits MSGComponentBase - + @if (string.IsNullOrWhiteSpace(this.Text)) { @T("Use file content as input") @@ -8,4 +8,4 @@ { @this.Text } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 5db85e21..d3248937 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -15,6 +15,9 @@ public partial class ReadFileContent : MSGComponentBase [Parameter] public EventCallback FileContentChanged { get; set; } + + [Parameter] + public bool Disabled { get; set; } [Inject] private RustService RustService { get; init; } = null!; @@ -30,6 +33,9 @@ public partial class ReadFileContent : MSGComponentBase private async Task SelectFile() { + if (this.Disabled) + return; + // Ensure that Pandoc is installed and ready: var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync( showSuccessMessage: false, @@ -73,4 +79,4 @@ public partial class ReadFileContent : MSGComponentBase await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, T("Failed to load file content"))); } } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Dialogs/Settings/NoSettingsPanel.razor b/app/MindWork AI Studio/Dialogs/Settings/NoSettingsPanel.razor new file mode 100644 index 00000000..132ca131 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/Settings/NoSettingsPanel.razor @@ -0,0 +1,2 @@ +@namespace AIStudio.Dialogs.Settings + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor deleted file mode 100644 index 2b9744b0..00000000 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor +++ /dev/null @@ -1,28 +0,0 @@ -@using AIStudio.Settings -@inherits SettingsDialogBase - - - - - - - @T("Assistant: Document Analysis") - - - - - - - - - - @T("Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function.") - - - - - - @T("Close") - - - \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor.cs deleted file mode 100644 index caafcb44..00000000 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDocumentAnalysis.razor.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace AIStudio.Dialogs.Settings; - -public partial class SettingsDialogDocumentAnalysis : SettingsDialogBase; \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 250aa06c..610a68cd 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -45,7 +45,7 @@
- + diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index ff421ff7..73061b73 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -49,26 +49,26 @@ CONFIG = {} CONFIG["LLM_PROVIDERS"] = {} -- An example of a configuration for a self-hosted server: -CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { - ["Id"] = "00000000-0000-0000-0000-000000000000", - ["InstanceName"] = "", - ["UsedLLMProvider"] = "SELF_HOSTED", - - -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, and VLLM - ["Host"] = "OLLAMA", - ["Hostname"] = "", - - -- Optional: Additional parameters for the API. - -- Please refer to the documentation of the selected host for details. - -- Might be something like ... \"temperature\": 0.5 ... for one parameter. - -- Could be something like ... \"temperature\": 0.5, \"max_tokens\": 1000 ... for multiple parameters. - -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. - ["AdditionalJsonApiParameters"] = "", - ["Model"] = { - ["Id"] = "", - ["DisplayName"] = "", - } -} +-- CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["InstanceName"] = "", +-- ["UsedLLMProvider"] = "SELF_HOSTED", +-- +-- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, and VLLM +-- ["Host"] = "OLLAMA", +-- ["Hostname"] = "", +-- +-- -- Optional: Additional parameters for the API. +-- -- Please refer to the documentation of the selected host for details. +-- -- Might be something like ... \"temperature\": 0.5 ... for one parameter. +-- -- Could be something like ... \"temperature\": 0.5, \"max_tokens\": 1000 ... for multiple parameters. +-- -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. +-- ["AdditionalJsonApiParameters"] = "", +-- ["Model"] = { +-- ["Id"] = "", +-- ["DisplayName"] = "", +-- } +-- } -- Transcription providers for voice-to-text functionality: CONFIG["TRANSCRIPTION_PROVIDERS"] = {} @@ -167,60 +167,97 @@ CONFIG["SETTINGS"] = {} CONFIG["CHAT_TEMPLATES"] = {} -- A simple example chat template: -CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { - ["Id"] = "00000000-0000-0000-0000-000000000000", - ["Name"] = "", - ["SystemPrompt"] = "You are 's helpful AI assistant for . Your task is ...", - ["PredefinedUserPrompt"] = "Please help me with ...", - ["AllowProfileUsage"] = true, - ["ExampleConversation"] = { - { - -- Allowed values are: USER, AI, SYSTEM - ["Role"] = "USER", - ["Content"] = "Hello! Can you help me with a quick task?" - }, - { - -- Allowed values are: USER, AI, SYSTEM - ["Role"] = "AI", - ["Content"] = "Of course. What do you need?" - } - } -} +-- CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["SystemPrompt"] = "You are 's helpful AI assistant for . Your task is ...", +-- ["PredefinedUserPrompt"] = "Please help me with ...", +-- ["AllowProfileUsage"] = true, +-- ["ExampleConversation"] = { +-- { +-- -- Allowed values are: USER, AI, SYSTEM +-- ["Role"] = "USER", +-- ["Content"] = "Hello! Can you help me with a quick task?" +-- }, +-- { +-- -- Allowed values are: USER, AI, SYSTEM +-- ["Role"] = "AI", +-- ["Content"] = "Of course. What do you need?" +-- } +-- } +-- } -- An example chat template with file attachments: -- This template automatically attaches specified files when the user selects it. -CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { - ["Id"] = "00000000-0000-0000-0000-000000000001", - ["Name"] = "Document Analysis Template", - ["SystemPrompt"] = "You are an expert document analyst. Please analyze the attached documents and provide insights.", - ["PredefinedUserPrompt"] = "Please analyze the attached company guidelines and summarize the key points.", - ["AllowProfileUsage"] = true, - -- Optional: Pre-attach files that will be automatically included when using this template. - -- These files will be loaded when the user selects this chat template. - -- Note: File paths must be absolute paths and accessible to all users. - ["FileAttachments"] = { - "G:\\Company\\Documents\\Guidelines.pdf", - "G:\\Company\\Documents\\CompanyPolicies.docx" - }, - ["ExampleConversation"] = { - { - ["Role"] = "USER", - ["Content"] = "I have attached the company documents for analysis." - }, - { - ["Role"] = "AI", - ["Content"] = "Thank you. I'll analyze the documents and provide a comprehensive summary." - } - } -} +-- CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000001", +-- ["Name"] = "Document Analysis Template", +-- ["SystemPrompt"] = "You are an expert document analyst. Please analyze the attached documents and provide insights.", +-- ["PredefinedUserPrompt"] = "Please analyze the attached company guidelines and summarize the key points.", +-- ["AllowProfileUsage"] = true, +-- -- Optional: Pre-attach files that will be automatically included when using this template. +-- -- These files will be loaded when the user selects this chat template. +-- -- Note: File paths must be absolute paths and accessible to all users. +-- ["FileAttachments"] = { +-- "G:\\Company\\Documents\\Guidelines.pdf", +-- "G:\\Company\\Documents\\CompanyPolicies.docx" +-- }, +-- ["ExampleConversation"] = { +-- { +-- ["Role"] = "USER", +-- ["Content"] = "I have attached the company documents for analysis." +-- }, +-- { +-- ["Role"] = "AI", +-- ["Content"] = "Thank you. I'll analyze the documents and provide a comprehensive summary." +-- } +-- } +-- } + +-- Document analysis policies for this configuration: +CONFIG["DOCUMENT_ANALYSIS_POLICIES"] = {} + +-- An example document analysis policy: +-- CONFIG["DOCUMENT_ANALYSIS_POLICIES"][#CONFIG["DOCUMENT_ANALYSIS_POLICIES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["PolicyName"] = "Compliance Summary Policy", +-- ["PolicyDescription"] = "Summarizes compliance-relevant clauses, obligations, and deadlines found in provided documents.", +-- +-- ["AnalysisRules"] = [===[ +-- Focus on compliance obligations, deadlines, and required actions. +-- Ignore marketing content and high-level summaries. +-- Flag any ambiguous or missing information. +-- ]===], +-- +-- ["OutputRules"] = [===[ +-- Provide a Markdown report with headings for Obligations, Deadlines, +-- and Open Questions. +-- ]===], +-- +-- -- Optional: minimum provider confidence required for this policy. +-- -- Allowed values are: NONE, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH +-- ["MinimumProviderConfidence"] = "MEDIUM", +-- +-- -- Optional: preselect a provider or profile by ID. +-- -- The IDs must exist in CONFIG["LLM_PROVIDERS"] or CONFIG["PROFILES"]. +-- ["PreselectedProvider"] = "00000000-0000-0000-0000-000000000000", +-- ["PreselectedProfile"] = "00000000-0000-0000-0000-000000000000", +-- +-- -- Optional: hide the policy definition section in the UI. +-- -- When set to true, users will only see the document selection interface +-- -- and cannot view or modify the policy settings. +-- -- This is useful for enterprise configurations where policy details should remain hidden. +-- -- Allowed values are: true, false (default: false) +-- ["HidePolicyDefinition"] = false +-- } -- Profiles for this configuration: CONFIG["PROFILES"] = {} -- A simple profile template: -CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { - ["Id"] = "00000000-0000-0000-0000-000000000000", - ["Name"] = "", - ["NeedToKnow"] = "I like to cook in my free time. My favorite meal is ...", - ["Actions"] = "Please always ensure the portion size is ..." -} \ No newline at end of file +-- CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["NeedToKnow"] = "I like to cook in my free time. My favorite meal is ...", +-- ["Actions"] = "Please always ensure the portion size is ..." +-- } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index cd9768f8..6adbe50f 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 @@ -399,9 +399,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description of your analysis rules. This rules will be used to instruct the AI on how to analyze the documents. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1291179736"] = "Bitte geben Sie eine Beschreibung Ihrer Analyseregeln an. Diese Regeln werden verwendet, um die KI anzuweisen, wie die Dokumente analysiert werden sollen." --- Not implemented yet. -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1568777658"] = "Noch nicht implementiert." - -- Yes, protect this policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1762380857"] = "Ja, dieses Regelwerk schützen" @@ -411,9 +408,18 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description for your policy. This description will be used to inform users about the purpose of your document analysis policy. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1837166236"] = "Bitte geben Sie eine Beschreibung für Ihr Regelwerk an. Diese Beschreibung wird verwendet, um Benutzer über den Zweck Ihres Regelwerks zur Dokumentenanalyse zu informieren." +-- Hide the policy definition when distributed via configuration plugin? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1875622568"] = "Die Definition des Regelwerks ausblenden, wenn diese über ein Konfigurations-Plugin verteilt wird?" + -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Allgemeine Einstellungen" +-- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Hinweis: Diese Einstellung wird nur wirksam, wenn dieses Regelwerk exportiert und über ein Konfigurations-Plugin an andere Nutzer verteilt wird. Wenn sie aktiviert ist, sehen Nutzer nur die Oberfläche zur Dokumentauswahl und können die Details des Regelwerks weder anzeigen noch ändern. Diese Einstellung wirkt sich NICHT auf Ihre lokale Ansicht aus – Sie sehen bei von Ihnen erstellten Regelwerken immer die Definition." + +-- This policy is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2035084381"] = "Dieses Regelwerk wird von Ihrer Organisation verwaltet." + -- The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T206207667"] = "Der Assistent für Dokumentenanalyse hilft Ihnen dabei, Informationen aus Dokumenten basierend auf vordefinierten Regelwerken zu analysieren und zu extrahieren. Sie können Regelwerke für die Dokumentenanalyse erstellen, bearbeiten und verwalten, die festlegen, wie Dokumente verarbeitet werden und welche Informationen extrahiert werden sollen. Einige Regelwerke könnten durch Ihre Organisation geschützt sein und können nicht geändert oder gelöscht werden." @@ -453,6 +459,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy name UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2879019438"] = "Name des Regelwerks" +-- No policy is selected. Please select a policy to export. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2929693091"] = "Es ist kein Regelwerk ausgewählt. Bitte wählen Sie ein Regelwerk zum Exportieren aus." + -- Policy Description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3023558273"] = "Beschreibung des Regelwerks" @@ -468,6 +477,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3157740273"] = "Regelwerk {0}" +-- No, show the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3166091879"] = "Nein, zeige die Definition des Regelwerks" + -- The description of your policy must be between 32 and 512 characters long. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3285636934"] = "Die Beschreibung des Regelwerks muss zwischen 32 und 512 Zeichen lang sein." @@ -510,6 +522,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T726434276"] = "Nachdem die KI alle Dokumente verarbeitet hat, benötigt sie Ihre Anweisungen, wie das Ergebnis formatiert werden soll. Möchten Sie eine strukturierte Liste mit Schlüsselwörtern oder einen fließenden Text? Soll die Ausgabe Emojis enthalten oder in formeller Geschäftssprache verfasst sein? Alle diese Präferenzen können Sie in den Ausgaberegeln festlegen. Dort können Sie auch eine gewünschte Struktur vordefinieren – zum Beispiel durch Verwendung von Markdown-Formatierung, um Überschriften, Absätze oder Aufzählungspunkte zu definieren." +-- The selected policy contains invalid data. Please fix the issues before exporting the policy. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T736334861"] = "Das ausgewählte Regelwerk enthält ungültige Daten. Bitte beheben Sie die Probleme, bevor Sie das Regelwerk exportieren." + -- Policy description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T748735777"] = "Beschreibung des Regelwerks" @@ -519,6 +534,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Here you have the option to save different policies for various document analysis assistants and switch between them. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T848153710"] = "Hier haben Sie die Möglichkeit, verschiedene Regelwerke für unterschiedliche Dokumentenanalysen zu speichern und zwischen ihnen zu wechseln." +-- Yes, hide the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T940701960"] = "Ja, die Definition des Regelwerks ausblenden" + -- Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T1143222914"] = "Geben Sie eine Liste von Stichpunkten sowie einige Basisinformationen für eine E-Mail ein. Der Assistent erstellt anschließend eine E-Mail auf Grundlage ihrer Angaben." @@ -4029,33 +4047,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473 -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Lokaler Ordner" --- Assistant: Document Analysis -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1372406750"] = "Assistent: Dokumentenanalyse" - --- Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1870328357"] = "Die meisten Optionen für die Analyse von Dokumenten können im Assistenten angepasst und direkt gespeichert werden. Dafür verfügt der Assistent über eine automatische Speicherfunktion." - --- Would you like to preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2221665527"] = "Möchten Sie eines Ihrer Profile vorab auswählen?" - --- Preselect document analysis options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2230062650"] = "Dokumentenanalyse-Optionen vorab auswählen?" - --- When enabled, you can preselect some document analysis options. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2301091111"] = "Wenn aktiviert, können Sie einige Dokumentanalyse-Optionen vorab auswählen." - --- No document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3317802895"] = "Keine Dokumentenanalyse-Optionen sind vorausgewählt." - --- Close -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3448155331"] = "Schließen" - --- Document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3945756386"] = "Dokumentenanalyse-Optionen sind vorausgewählt." - --- Preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T4004501229"] = "Eines Ihrer Profile vorauswählen?" - -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "Wenn aktiviert, können Sie einige ERI-Serveroptionen vorauswählen." 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 d4fa1ba7..5c164305 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 @@ -399,9 +399,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description of your analysis rules. This rules will be used to instruct the AI on how to analyze the documents. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1291179736"] = "Please provide a description of your analysis rules. This rules will be used to instruct the AI on how to analyze the documents." --- Not implemented yet. -UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1568777658"] = "Not implemented yet." - -- Yes, protect this policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1762380857"] = "Yes, protect this policy" @@ -411,9 +408,18 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Please provide a description for your policy. This description will be used to inform users about the purpose of your document analysis policy. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1837166236"] = "Please provide a description for your policy. This description will be used to inform users about the purpose of your document analysis policy." +-- Hide the policy definition when distributed via configuration plugin? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1875622568"] = "Hide the policy definition when distributed via configuration plugin?" + -- Common settings UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1963959073"] = "Common settings" +-- Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1984494439"] = "Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create." + +-- This policy is managed by your organization. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2035084381"] = "This policy is managed by your organization." + -- The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T206207667"] = "The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted." @@ -453,6 +459,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy name UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2879019438"] = "Policy name" +-- No policy is selected. Please select a policy to export. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2929693091"] = "No policy is selected. Please select a policy to export." + -- Policy Description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3023558273"] = "Policy Description" @@ -468,6 +477,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Policy {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3157740273"] = "Policy {0}" +-- No, show the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3166091879"] = "No, show the policy definition" + -- The description of your policy must be between 32 and 512 characters long. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3285636934"] = "The description of your policy must be between 32 and 512 characters long." @@ -510,6 +522,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T726434276"] = "After the AI has processed all documents, it needs your instructions on how the result should be formatted. Would you like a structured list with keywords or a continuous text? Should the output include emojis or be written in formal business language? You can specify all these preferences in the output rules. There, you can also predefine a desired structure—for example, by using Markdown formatting to define headings, paragraphs, or bullet points." +-- The selected policy contains invalid data. Please fix the issues before exporting the policy. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T736334861"] = "The selected policy contains invalid data. Please fix the issues before exporting the policy." + -- Policy description UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T748735777"] = "Policy description" @@ -519,6 +534,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Here you have the option to save different policies for various document analysis assistants and switch between them. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T848153710"] = "Here you have the option to save different policies for various document analysis assistants and switch between them." +-- Yes, hide the policy definition +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T940701960"] = "Yes, hide the policy definition" + -- Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T1143222914"] = "Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input." @@ -4029,33 +4047,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T774473 -- Local Directory UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDATASOURCES::T926703547"] = "Local Directory" --- Assistant: Document Analysis -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1372406750"] = "Assistant: Document Analysis" - --- Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T1870328357"] = "Most document analysis options can be customized and saved directly in the assistant. For this, the assistant has an auto-save function." - --- Would you like to preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2221665527"] = "Would you like to preselect one of your profiles?" - --- Preselect document analysis options? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2230062650"] = "Preselect document analysis options?" - --- When enabled, you can preselect some document analysis options. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T2301091111"] = "When enabled, you can preselect some document analysis options." - --- No document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3317802895"] = "No document analysis options are preselected" - --- Close -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3448155331"] = "Close" - --- Document analysis options are preselected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T3945756386"] = "Document analysis options are preselected" - --- Preselect one of your profiles? -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGDOCUMENTANALYSIS::T4004501229"] = "Preselect one of your profiles?" - -- When enabled, you can preselect some ERI server options. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGERISERVER::T1280666275"] = "When enabled, you can preselect some ERI server options." diff --git a/app/MindWork AI Studio/Settings/ChatTemplate.cs b/app/MindWork AI Studio/Settings/ChatTemplate.cs index 5f3c597a..78879a62 100644 --- a/app/MindWork AI Studio/Settings/ChatTemplate.cs +++ b/app/MindWork AI Studio/Settings/ChatTemplate.cs @@ -107,7 +107,7 @@ public record ChatTemplate( template = new ChatTemplate { - Num = 0, + Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), Name = name, SystemPrompt = systemPrompt, diff --git a/app/MindWork AI Studio/Settings/DataModel/Data.cs b/app/MindWork AI Studio/Settings/DataModel/Data.cs index 622d737e..f174d6c2 100644 --- a/app/MindWork AI Studio/Settings/DataModel/Data.cs +++ b/app/MindWork AI Studio/Settings/DataModel/Data.cs @@ -80,6 +80,11 @@ public sealed class Data /// The next chat template number to use. /// public uint NextChatTemplateNum { get; set; } = 1; + + /// + /// The next document analysis policy number to use. + /// + public uint NextDocumentAnalysisPolicyNum { get; set; } = 1; public DataApp App { get; init; } = new(x => x.App); diff --git a/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs b/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs index aae8ede1..f2cbcfea 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataDocumentAnalysisPolicy.cs @@ -1,16 +1,37 @@ using AIStudio.Provider; +using AIStudio.Tools.PluginSystem; + +using Lua; namespace AIStudio.Settings.DataModel; -public sealed class DataDocumentAnalysisPolicy +public sealed record DataDocumentAnalysisPolicy : ConfigurationBaseObject { + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); + + /// + public override string Id { get; init; } = string.Empty; + + /// + public override uint Num { get; init; } + + /// + public override string Name + { + get => this.PolicyName; + init => this.PolicyName = value; + } + + /// + public override bool IsEnterpriseConfiguration { get; init; } + /// - /// Preselect the policy name? + /// The name of the document analysis policy. /// public string PolicyName { get; set; } = string.Empty; /// - /// Preselect the policy description? + /// The description of the document analysis policy. /// public string PolicyDescription { get; set; } = string.Empty; @@ -18,12 +39,9 @@ public sealed class DataDocumentAnalysisPolicy /// Is this policy protected? If so, it cannot be deleted or modified by the user. /// public bool IsProtected { get; set; } - - /// - /// Is this a managed policy? Managed policies are created and managed by the organization - /// and cannot be modified or deleted by the user. - /// - public bool IsManaged { get; set; } + + /// + public override Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// /// The rules for the document analysis policy. @@ -49,4 +67,86 @@ public sealed class DataDocumentAnalysisPolicy /// Preselect a profile? /// public string PreselectedProfile { get; set; } = string.Empty; + + /// + /// Hide the policy definition section in the UI? + /// If true, the policy definition panel will be hidden and only the document selection will be shown. + /// This is useful for enterprise configurations where users should not see or modify the policy details. + /// + public bool HidePolicyDefinition { get; set; } + + public static bool TryProcessConfiguration(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject policy) + { + policy = new DataDocumentAnalysisPolicy(); + if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} does not contain a valid ID. The ID must be a valid GUID.", idx); + return false; + } + + if (!table.TryGetValue("PolicyName", out var nameValue) || !nameValue.TryRead(out var name) || string.IsNullOrWhiteSpace(name)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} does not contain a valid PolicyName field.", idx); + return false; + } + + if (!table.TryGetValue("PolicyDescription", out var descriptionValue) || !descriptionValue.TryRead(out var description) || string.IsNullOrWhiteSpace(description)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} does not contain a valid PolicyDescription field.", idx); + return false; + } + + if (!table.TryGetValue("AnalysisRules", out var analysisRulesValue) || !analysisRulesValue.TryRead(out var analysisRules) || string.IsNullOrWhiteSpace(analysisRules)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} does not contain valid AnalysisRules field.", idx); + return false; + } + + if (!table.TryGetValue("OutputRules", out var outputRulesValue) || !outputRulesValue.TryRead(out var outputRules) || string.IsNullOrWhiteSpace(outputRules)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} does not contain valid OutputRules field.", idx); + return false; + } + + var minimumConfidence = ConfidenceLevel.NONE; + if (table.TryGetValue("MinimumProviderConfidence", out var minConfValue) && minConfValue.TryRead(out var minConfText)) + { + if (!Enum.TryParse(minConfText, true, out minimumConfidence)) + { + LOG.LogWarning("The configured document analysis policy {PolicyIndex} contains an invalid MinimumProviderConfidence: {ConfidenceLevel}.", idx, minConfText); + minimumConfidence = ConfidenceLevel.NONE; + } + } + + var preselectedProvider = string.Empty; + if (table.TryGetValue("PreselectedProvider", out var providerValue) && providerValue.TryRead(out var providerId)) + preselectedProvider = providerId; + + var preselectedProfile = string.Empty; + if (table.TryGetValue("PreselectedProfile", out var profileValue) && profileValue.TryRead(out var profileId)) + preselectedProfile = profileId; + + var hidePolicyDefinition = false; + if (table.TryGetValue("HidePolicyDefinition", out var hideValue) && hideValue.TryRead(out var hide)) + hidePolicyDefinition = hide; + + policy = new DataDocumentAnalysisPolicy + { + Id = id.ToString(), + Num = 0, // will be set later by the PluginConfigurationObject + PolicyName = name, + PolicyDescription = description, + AnalysisRules = analysisRules, + OutputRules = outputRules, + MinimumProviderConfidence = minimumConfidence, + PreselectedProvider = preselectedProvider, + PreselectedProfile = preselectedProfile, + HidePolicyDefinition = hidePolicyDefinition, + IsProtected = true, + IsEnterpriseConfiguration = true, + EnterpriseConfigurationPluginId = configPluginId, + }; + + return true; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index c5e48d4f..e88831f0 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -98,7 +98,7 @@ public sealed record EmbeddingProvider( provider = new EmbeddingProvider { - Num = 0, + Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), Name = name, UsedLLMProvider = usedLLMProvider, diff --git a/app/MindWork AI Studio/Settings/Profile.cs b/app/MindWork AI Studio/Settings/Profile.cs index 9d0eddfd..ac657ba6 100644 --- a/app/MindWork AI Studio/Settings/Profile.cs +++ b/app/MindWork AI Studio/Settings/Profile.cs @@ -122,7 +122,7 @@ public record Profile( template = new Profile { - Num = 0, + Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), Name = name, NeedToKnow = needToKnow, diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 82340485..89a0dbbd 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -143,7 +143,7 @@ public sealed record Provider( provider = new Provider { - Num = 0, + Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), InstanceName = instanceName, UsedLLMProvider = usedLLMProvider, diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index 02b20792..7a5f2ef5 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -98,7 +98,7 @@ public sealed record TranscriptionProvider( provider = new TranscriptionProvider { - Num = 0, + Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), Name = name, UsedLLMProvider = usedLLMProvider, diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 54eb2cfa..721ae79d 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -16,6 +16,7 @@ public static class ComponentsExtensions Components.ERI_ASSISTANT => false, Components.BIAS_DAY_ASSISTANT => false, Components.I18N_ASSISTANT => false, + Components.DOCUMENT_ANALYSIS_ASSISTANT => false, Components.APP_SETTINGS => false, @@ -87,8 +88,9 @@ public static class ComponentsExtensions Components.BIAS_DAY_ASSISTANT => settingsManager.ConfigurationData.BiasOfTheDay.PreselectOptions ? settingsManager.ConfigurationData.BiasOfTheDay.MinimumProviderConfidence : default, Components.ERI_ASSISTANT => settingsManager.ConfigurationData.ERI.PreselectOptions ? settingsManager.ConfigurationData.ERI.MinimumProviderConfidence : default, - #warning Add minimum confidence for DOCUMENT_ANALYSIS_ASSISTANT: - //Components.DOCUMENT_ANALYSIS_ASSISTANT => settingsManager.ConfigurationData.DocumentAnalysis.PreselectOptions ? settingsManager.ConfigurationData.DocumentAnalysis.MinimumProviderConfidence : default, + // The minimum confidence for the Document Analysis Assistant is set per policy. + // We do this inside the Document Analysis Assistant component: + Components.DOCUMENT_ANALYSIS_ASSISTANT => ConfidenceLevel.NONE, _ => default, }; @@ -114,8 +116,9 @@ public static class ComponentsExtensions Components.ERI_ASSISTANT => settingsManager.ConfigurationData.ERI.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.ERI.PreselectedProvider) : null, Components.I18N_ASSISTANT => settingsManager.ConfigurationData.I18N.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.I18N.PreselectedProvider) : null, - #warning Add preselected provider for DOCUMENT_ANALYSIS_ASSISTANT: - //Components.DOCUMENT_ANALYSIS_ASSISTANT => settingsManager.ConfigurationData.DocumentAnalysis.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.DocumentAnalysis.PreselectedProvider) : null, + // The Document Analysis Assistant does not have a preselected provider at the component level. + // The provider is selected per policy instead. We do this inside the Document Analysis Assistant component. + Components.DOCUMENT_ANALYSIS_ASSISTANT => Settings.Provider.NONE, Components.CHAT => settingsManager.ConfigurationData.Chat.PreselectOptions ? settingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Chat.PreselectedProvider) : null, @@ -131,8 +134,6 @@ public static class ComponentsExtensions public static Profile PreselectedProfile(this Components component, SettingsManager settingsManager) => component switch { - #warning Add preselected profile for DOCUMENT_ANALYSIS_ASSISTANT: - // Components.DOCUMENT_ANALYSIS_ASSISTANT => settingsManager.ConfigurationData.DocumentAnalysis.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.DocumentAnalysis.PreselectedProfile) : default, Components.AGENDA_ASSISTANT => settingsManager.ConfigurationData.Agenda.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Agenda.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, Components.CODING_ASSISTANT => settingsManager.ConfigurationData.Coding.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Coding.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, Components.EMAIL_ASSISTANT => settingsManager.ConfigurationData.EMail.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.EMail.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, @@ -141,6 +142,10 @@ public static class ComponentsExtensions Components.BIAS_DAY_ASSISTANT => settingsManager.ConfigurationData.BiasOfTheDay.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.BiasOfTheDay.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, Components.ERI_ASSISTANT => settingsManager.ConfigurationData.ERI.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.ERI.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, + // The Document Analysis Assistant does not have a preselected profile at the component level. + // The profile is selected per policy instead. We do this inside the Document Analysis Assistant component: + Components.DOCUMENT_ANALYSIS_ASSISTANT => Profile.NO_PROFILE, + Components.CHAT => settingsManager.ConfigurationData.Chat.PreselectOptions ? settingsManager.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Chat.PreselectedProfile) ?? Profile.NO_PROFILE : Profile.NO_PROFILE, _ => Profile.NO_PROFILE, diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index e85c8eba..29d95e76 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -88,6 +88,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Handle configured profiles: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun); + // 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 profile? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreselectedProfile, Guid.Empty, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index da5c46c2..647c79bf 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -70,6 +70,7 @@ public sealed record PluginConfigurationObject PluginConfigurationObjectType.EMBEDDING_PROVIDER => "EMBEDDING_PROVIDERS", PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => "TRANSCRIPTION_PROVIDERS", PluginConfigurationObjectType.PROFILE => "PROFILES", + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => "DOCUMENT_ANALYSIS_POLICIES", _ => null, }; @@ -105,6 +106,7 @@ public sealed record PluginConfigurationObject PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject), PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject), PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject), + PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => (DataDocumentAnalysisPolicy.TryProcessConfiguration(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject is DataDocumentAnalysisPolicy, configurationObject), _ => (false, NoConfigurationObject.INSTANCE) }; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectType.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectType.cs index 82931873..4236af12 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectType.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObjectType.cs @@ -11,4 +11,5 @@ public enum PluginConfigurationObjectType CHAT_TEMPLATE, EMBEDDING_PROVIDER, TRANSCRIPTION_PROVIDER, + DOCUMENT_ANALYSIS_POLICY, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index bdfdba81..b2b45aba 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -148,6 +148,10 @@ public static partial class PluginFactory // Check profiles: if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; + + // Check document analysis policies: + if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) + wasConfigurationChanged = true; // Check for a preselected profile: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreselectedProfile, AVAILABLE_PLUGINS)) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index 13dde977..4cd8fdb9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -1,2 +1,6 @@ # v26.2.1, build 233 (2026-02-xx xx:xx UTC) +- Added the ability to individually configure the minimum confidence level, standard profile, and default provider for each policy in the Document Analysis Assistant (in preview). +- Added support for defining document analysis policies (in preview) by configuration plugins, enabling centralized management of policies across entire departments or organizations. +- Added an option to hide the policy definition in the Document Analysis Assistant (in preview) when exporting and distributing that policy by a configuration plugin in organizations, making it easier for users to use. +- Added the policy export functionality to the Document Analysis Assistant (in preview). You can now export policies as Lua code for a configuration plugin to distribute the policy across your organization. - Fixed a bug where the global minimum confidence level was not being applied to the assistants. \ No newline at end of file From 5ea92c9ced612e4e46788d9d8cc02279cfe8a12c Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 19:30:41 +0100 Subject: [PATCH 03/34] Fixed the component set for the chat and writer page (#648) --- app/MindWork AI Studio/Pages/Chat.razor | 6 ++++-- app/MindWork AI Studio/Pages/Writer.razor | 6 ++++-- app/MindWork AI Studio/Tools/Components.cs | 1 + app/MindWork AI Studio/Tools/ComponentsExtensions.cs | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index c69304bb..1b2df035 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -29,7 +29,9 @@ - + + + @if (this.AreWorkspacesVisible) { @@ -143,4 +145,4 @@ } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Pages/Writer.razor b/app/MindWork AI Studio/Pages/Writer.razor index bead1792..503a284f 100644 --- a/app/MindWork AI Studio/Pages/Writer.razor +++ b/app/MindWork AI Studio/Pages/Writer.razor @@ -7,7 +7,9 @@ - + + + - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Tools/Components.cs b/app/MindWork AI Studio/Tools/Components.cs index 1004188c..45ccba91 100644 --- a/app/MindWork AI Studio/Tools/Components.cs +++ b/app/MindWork AI Studio/Tools/Components.cs @@ -25,6 +25,7 @@ public enum Components // ReSharper restore InconsistentNaming CHAT, + WRITER, APP_SETTINGS, AGENT_TEXT_CONTENT_CLEANER, diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 721ae79d..4e346d82 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -19,6 +19,7 @@ public static class ComponentsExtensions Components.DOCUMENT_ANALYSIS_ASSISTANT => false, Components.APP_SETTINGS => false, + Components.WRITER => false, Components.AGENT_TEXT_CONTENT_CLEANER => false, Components.AGENT_DATA_SOURCE_SELECTION => false, From 0e14a3cb58270fe5814076ca80e2f36961dcc3d5 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 19:51:12 +0100 Subject: [PATCH 04/34] Improved PDFium initialization and error handling logic (#649) --- .../wwwroot/changelog/v26.2.1.md | 1 + runtime/src/file_data.rs | 10 ++++- runtime/src/pdfium.rs | 39 ++++++++++++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index 4cd8fdb9..a743739d 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -3,4 +3,5 @@ - Added support for defining document analysis policies (in preview) by configuration plugins, enabling centralized management of policies across entire departments or organizations. - Added an option to hide the policy definition in the Document Analysis Assistant (in preview) when exporting and distributing that policy by a configuration plugin in organizations, making it easier for users to use. - Added the policy export functionality to the Document Analysis Assistant (in preview). You can now export policies as Lua code for a configuration plugin to distribute the policy across your organization. +- Improved the error checking & logging behavior when the installed `PDFium` version did not meet the minimum required version. - Fixed a bug where the global minimum confidence level was not being applied to the assistants. \ No newline at end of file diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index 969f19e1..b0ba1b24 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -246,7 +246,13 @@ async fn stream_pdf(file_path: &str) -> Result { let (tx, rx) = mpsc::channel(10); tokio::task::spawn_blocking(move || { - let pdfium = Pdfium::ai_studio_init(); + let pdfium = match Pdfium::ai_studio_init() { + Ok(pdfium) => pdfium, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return; + } + }; let doc = match pdfium.load_pdf_from_file(&path, None) { Ok(document) => document, Err(e) => { @@ -464,4 +470,4 @@ async fn stream_pptx(file_path: &str, extract_images: bool) -> Result>> = Lazy::new(|| Mutex::new(None)); pub trait PdfiumInit { - fn ai_studio_init() -> Pdfium; + fn ai_studio_init() -> Result>; } impl PdfiumInit for Pdfium { /// Initializes the PDFium library for AI Studio. - fn ai_studio_init() -> Pdfium { + fn ai_studio_init() -> Result> { let lib_path = PDFIUM_LIB_PATH.lock().unwrap(); if let Some(path) = lib_path.as_ref() { - return Pdfium::new( - Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) - .or_else(|_| Pdfium::bind_to_system_library()) - .unwrap(), - ); + return match Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path(path)) { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(library_error) => { + match Pdfium::bind_to_system_library() { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(system_error) => { + error!( + "Failed to load PDFium from '{path}' and the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: library error: '{library_error}'; system error: '{system_error}'." + ); + + Err(Box::new(system_error)) + } + } + } + } } - Pdfium::new(Pdfium::bind_to_system_library().unwrap()) + warn!("No custom PDFium library path set; trying to load PDFium from the system library."); + match Pdfium::bind_to_system_library() { + Ok(binding) => Ok(Pdfium::new(binding)), + Err(system_error) => { + error!( + "Failed to load PDFium from the system library. Developer action (from repo root): run the build script once to download the required PDFium version: `cd app/Build` and `dotnet run build`. Details: '{system_error}'." + ); + + Err(Box::new(system_error)) + } + } } -} \ No newline at end of file +} From f1fa8fd8a0b8c27b07b9468b1068020016509aaf Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 20:00:13 +0100 Subject: [PATCH 05/34] Improved logging for profile table parsing (#650) --- app/MindWork AI Studio/Settings/Profile.cs | 1 - app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Settings/Profile.cs b/app/MindWork AI Studio/Settings/Profile.cs index ac657ba6..2129b04c 100644 --- a/app/MindWork AI Studio/Settings/Profile.cs +++ b/app/MindWork AI Studio/Settings/Profile.cs @@ -94,7 +94,6 @@ public record Profile( public static bool TryParseProfileTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject template) { - LOGGER.LogInformation($"\n Profile table parsing {idx}.\n"); template = NO_PROFILE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) { diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index a743739d..85f7cbc7 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -4,4 +4,5 @@ - Added an option to hide the policy definition in the Document Analysis Assistant (in preview) when exporting and distributing that policy by a configuration plugin in organizations, making it easier for users to use. - Added the policy export functionality to the Document Analysis Assistant (in preview). You can now export policies as Lua code for a configuration plugin to distribute the policy across your organization. - Improved the error checking & logging behavior when the installed `PDFium` version did not meet the minimum required version. +- Improved the logging behavior when parsing profile tables from configuration plugins. - Fixed a bug where the global minimum confidence level was not being applied to the assistants. \ No newline at end of file From 8f9cd40d06086af84aa560afc89252e8317a3789 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 20:13:25 +0100 Subject: [PATCH 06/34] Upgraded voice recording & document analysis to the beta state (#651) --- .../DocumentAnalysis/DocumentAnalysisAssistant.razor | 2 +- .../Components/Settings/SettingsPanelTranscription.razor | 4 ++-- .../Settings/DataModel/PreviewVisibilityExtensions.cs | 4 ++-- app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index 93fc8846..aa77f9fb 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -2,7 +2,7 @@ @inherits AssistantBaseCore @using AIStudio.Settings.DataModel - +
diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 0405d6cd..aff415b2 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -5,7 +5,7 @@ @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { - + @T("Configured Transcription Providers") @@ -70,4 +70,4 @@ @T("Add transcription provider")
-} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs index 53612acc..30764bfe 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs @@ -11,6 +11,8 @@ public static class PreviewVisibilityExtensions if (visibility >= PreviewVisibility.BETA) { + features.Add(PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025); + features.Add(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026); } if (visibility >= PreviewVisibility.ALPHA) @@ -20,8 +22,6 @@ public static class PreviewVisibilityExtensions if (visibility >= PreviewVisibility.PROTOTYPE) { features.Add(PreviewFeatures.PRE_RAG_2024); - features.Add(PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025); - features.Add(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026); } if (visibility >= PreviewVisibility.EXPERIMENTAL) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index 85f7cbc7..a73038c8 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -5,4 +5,5 @@ - Added the policy export functionality to the Document Analysis Assistant (in preview). You can now export policies as Lua code for a configuration plugin to distribute the policy across your organization. - Improved the error checking & logging behavior when the installed `PDFium` version did not meet the minimum required version. - Improved the logging behavior when parsing profile tables from configuration plugins. -- Fixed a bug where the global minimum confidence level was not being applied to the assistants. \ No newline at end of file +- Fixed a bug where the global minimum confidence level was not being applied to the assistants. +- Upgraded the Document Analysis Assistant and the voice recording with transcription feature (both in preview) from the prototype to the beta state. Both features are now completely implemented and are undergoing a deeper testing phase in preparation for release. \ No newline at end of file From e65874d99b673b811c1405f46fe96e167d0981f2 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Feb 2026 20:19:30 +0100 Subject: [PATCH 07/34] Prepared release v26.2.1 (#652) --- app/MindWork AI Studio/Components/Changelog.Logs.cs | 1 + app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md | 2 +- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + metadata.txt | 8 ++++---- runtime/Cargo.lock | 2 +- runtime/Cargo.toml | 2 +- runtime/tauri.conf.json | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md diff --git a/app/MindWork AI Studio/Components/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Changelog.Logs.cs index dfeb2208..fec0b88e 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 (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"), new (230, "v0.10.0, build 230 (2025-12-31 14:04 UTC)", "v0.10.0.md"), diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md index a73038c8..7cc592aa 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.1.md @@ -1,4 +1,4 @@ -# v26.2.1, build 233 (2026-02-xx xx:xx UTC) +# v26.2.1, build 233 (2026-02-01 19:16 UTC) - Added the ability to individually configure the minimum confidence level, standard profile, and default provider for each policy in the Document Analysis Assistant (in preview). - Added support for defining document analysis policies (in preview) by configuration plugins, enabling centralized management of policies across entire departments or organizations. - Added an option to hide the policy definition in the Document Analysis Assistant (in preview) when exporting and distributing that policy by a configuration plugin in organizations, making it easier for users to use. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md new file mode 100644 index 00000000..23c3747c --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -0,0 +1 @@ +# v26.2.2, build 234 (2026-02-xx xx:xx UTC) diff --git a/metadata.txt b/metadata.txt index 97e7f7e8..fa80ecb5 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,11 +1,11 @@ -26.1.2 -2026-01-25 14:05:29 UTC -232 +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) 8.15.0 1.8.1 -37293e4a7cb, release +8f9cd40d060, release osx-arm64 144.0.7543.0 \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index f306d04c..48fb43c3 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2770,7 +2770,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "26.1.2" +version = "26.2.1" dependencies = [ "aes", "arboard", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index ca5587d8..c647be37 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindwork-ai-studio" -version = "26.1.2" +version = "26.2.1" edition = "2021" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 56ecc8dd..216b17e3 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -6,7 +6,7 @@ }, "package": { "productName": "MindWork AI Studio", - "version": "26.1.2" + "version": "26.2.1" }, "tauri": { "allowlist": { From 5af6a8db3ef36bf51e1145c65ff06721bb2e012b Mon Sep 17 00:00:00 2001 From: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:32:17 +0100 Subject: [PATCH 08/34] Add Qdrant as vector database (#580) Co-authored-by: Thorsten Sommer --- .github/workflows/build-and-release.yml | 136 ++++++- .gitignore | 7 + README.md | 2 +- app/Build/Commands/Database.cs | 3 + app/Build/Commands/Qdrant.cs | 120 ++++++ app/Build/Commands/UpdateMetadataCommands.cs | 13 + app/MindWork AI Studio.sln.DotSettings | 2 + .../Assistants/I18N/allTexts.lua | 15 + .../MindWork AI Studio.csproj | 5 + .../Pages/Information.razor | 26 ++ .../Pages/Information.razor.cs | 27 +- .../plugin.lua | 15 + .../plugin.lua | 15 + app/MindWork AI Studio/Program.cs | 42 +++ .../Tools/Databases/DatabaseClient.cs | 52 +++ .../Qdrant/QdrantClientImplementation.cs | 66 ++++ .../Metadata/MetaDataDatabasesAttribute.cs | 6 + .../Tools/Rust/QdrantInfo.cs | 17 + .../Tools/Services/RustService.Databases.cs | 25 ++ .../wwwroot/changelog/v26.2.2.md | 1 + documentation/Build.md | 12 +- metadata.txt | 3 +- runtime/Cargo.toml | 6 +- runtime/build.rs | 4 +- .../resources/databases/qdrant/config.yaml | 354 ++++++++++++++++++ runtime/src/api_token.rs | 58 +-- runtime/src/app_window.rs | 33 +- runtime/src/certificate.rs | 38 -- runtime/src/certificate_factory.rs | 32 ++ runtime/src/dotnet.rs | 26 +- runtime/src/lib.rs | 9 +- runtime/src/main.rs | 15 +- runtime/src/metadata.rs | 3 + runtime/src/qdrant.rs | 222 +++++++++++ runtime/src/runtime_api.rs | 3 +- runtime/src/runtime_api_token.rs | 40 ++ runtime/src/runtime_certificate.rs | 26 ++ runtime/src/sidecar_types.rs | 15 + runtime/src/stale_process_cleanup.rs | 89 +++++ runtime/tauri.conf.json | 8 +- 40 files changed, 1465 insertions(+), 126 deletions(-) create mode 100644 app/Build/Commands/Database.cs create mode 100644 app/Build/Commands/Qdrant.cs create mode 100644 app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs create mode 100644 app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs create mode 100644 app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustService.Databases.cs create mode 100644 runtime/resources/databases/qdrant/config.yaml delete mode 100644 runtime/src/certificate.rs create mode 100644 runtime/src/certificate_factory.rs create mode 100644 runtime/src/qdrant.rs create mode 100644 runtime/src/runtime_api_token.rs create mode 100644 runtime/src/runtime_certificate.rs create mode 100644 runtime/src/sidecar_types.rs create mode 100644 runtime/src/stale_process_cleanup.rs diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 8d1d8de4..091faafb 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -173,6 +173,9 @@ jobs: pdfium_version=$(sed -n '11p' metadata.txt) pdfium_version=$(echo $pdfium_version | cut -d'.' -f3) + # Next line is the Qdrant version: + qdrant_version="v$(sed -n '12p' metadata.txt)" + # Write the metadata to the environment: echo "APP_VERSION=${app_version}" >> $GITHUB_ENV echo "FORMATTED_APP_VERSION=${formatted_app_version}" >> $GITHUB_ENV @@ -185,6 +188,7 @@ jobs: echo "TAURI_VERSION=${tauri_version}" >> $GITHUB_ENV echo "ARCHITECTURE=${{ matrix.dotnet_runtime }}" >> $GITHUB_ENV echo "PDFIUM_VERSION=${pdfium_version}" >> $GITHUB_ENV + echo "QDRANT_VERSION=${qdrant_version}" >> $GITHUB_ENV # Log the metadata: echo "App version: '${formatted_app_version}'" @@ -197,6 +201,7 @@ jobs: echo "Tauri version: '${tauri_version}'" echo "Architecture: '${{ matrix.dotnet_runtime }}'" echo "PDFium version: '${pdfium_version}'" + echo "Qdrant version: '${qdrant_version}'" - name: Read and format metadata (Windows) if: matrix.platform == 'windows-latest' @@ -241,6 +246,9 @@ jobs: $pdfium_version = $metadata[10] $pdfium_version = $pdfium_version.Split('.')[2] + # Next line is the necessary Qdrant version: + $qdrant_version = "v$($metadata[11])" + # Write the metadata to the environment: Write-Output "APP_VERSION=${app_version}" >> $env:GITHUB_ENV Write-Output "FORMATTED_APP_VERSION=${formatted_app_version}" >> $env:GITHUB_ENV @@ -252,6 +260,7 @@ jobs: Write-Output "MUD_BLAZOR_VERSION=${mud_blazor_version}" >> $env:GITHUB_ENV Write-Output "ARCHITECTURE=${{ matrix.dotnet_runtime }}" >> $env:GITHUB_ENV Write-Output "PDFIUM_VERSION=${pdfium_version}" >> $env:GITHUB_ENV + Write-Output "QDRANT_VERSION=${qdrant_version}" >> $env:GITHUB_ENV # Log the metadata: Write-Output "App version: '${formatted_app_version}'" @@ -264,6 +273,7 @@ jobs: Write-Output "Tauri version: '${tauri_version}'" Write-Output "Architecture: '${{ matrix.dotnet_runtime }}'" Write-Output "PDFium version: '${pdfium_version}'" + Write-Output "Qdrant version: '${qdrant_version}'" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -334,7 +344,7 @@ jobs: echo "Cleaning up ..." rm -fr "$TMP" - - name: Install PDFium (Windows) + - name: Deploy PDFium (Windows) if: matrix.platform == 'windows-latest' env: PDFIUM_VERSION: ${{ env.PDFIUM_VERSION }} @@ -385,6 +395,128 @@ jobs: Write-Host "Cleaning up ..." Remove-Item $ARCHIVE -Force -ErrorAction SilentlyContinue + # Try to remove the temporary directory, but ignore errors if files are still in use + try { + Remove-Item $TMP -Recurse -Force -ErrorAction Stop + Write-Host "Successfully cleaned up temporary directory: $TMP" + } catch { + Write-Warning "Could not fully clean up temporary directory: $TMP. This is usually harmless as Windows will clean it up later. Error: $($_.Exception.Message)" + } + - name: Deploy Qdrant (Unix) + if: matrix.platform != 'windows-latest' + env: + QDRANT_VERSION: ${{ env.QDRANT_VERSION }} + DOTNET_RUNTIME: ${{ matrix.dotnet_runtime }} + RUST_TARGET: ${{ matrix.rust_target }} + run: | + set -e + + # Target directory: + TDB_DIR="runtime/target/databases/qdrant" + mkdir -p "$TDB_DIR" + + case "${DOTNET_RUNTIME}" in + linux-x64) + QDRANT_FILE="x86_64-unknown-linux-gnu.tar.gz" + DB_SOURCE="qdrant" + DB_TARGET="qdrant-${RUST_TARGET}" + ;; + linux-arm64) + QDRANT_FILE="aarch64-unknown-linux-musl.tar.gz" + DB_SOURCE="qdrant" + DB_TARGET="qdrant-${RUST_TARGET}" + ;; + osx-x64) + QDRANT_FILE="x86_64-apple-darwin.tar.gz" + DB_SOURCE="qdrant" + DB_TARGET="qdrant-${RUST_TARGET}" + ;; + osx-arm64) + QDRANT_FILE="aarch64-apple-darwin.tar.gz" + DB_SOURCE="qdrant" + DB_TARGET="qdrant-${RUST_TARGET}" + ;; + *) + echo "Unknown platform: ${DOTNET_RUNTIME}" + exit 1 + ;; + esac + + QDRANT_URL="https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_FILE}" + + echo "Download Qdrant $QDRANT_URL ..." + TMP=$(mktemp -d) + ARCHIVE="${TMP}/qdrant.tgz" + + curl -fsSL -o "$ARCHIVE" "$QDRANT_URL" + + echo "Extracting Qdrant ..." + tar xzf "$ARCHIVE" -C "$TMP" + SRC="${TMP}/${DB_SOURCE}" + + if [ ! -f "$SRC" ]; then + echo "Was not able to find Qdrant source: $SRC" + exit 1 + fi + + echo "Copy Qdrant from ${DB_TARGET} to ${TDB_DIR}/" + cp -f "$SRC" "$TDB_DIR/$DB_TARGET" + + echo "Cleaning up ..." + rm -fr "$TMP" + + - name: Deploy Qdrant (Windows) + if: matrix.platform == 'windows-latest' + env: + QDRANT_VERSION: ${{ env.QDRANT_VERSION }} + DOTNET_RUNTIME: ${{ matrix.dotnet_runtime }} + RUST_TARGET: ${{ matrix.rust_target }} + run: | + $TDB_DIR = "runtime\target\databases\qdrant" + New-Item -ItemType Directory -Force -Path $TDB_DIR | Out-Null + + switch ($env:DOTNET_RUNTIME) { + "win-x64" { + $QDRANT_FILE = "x86_64-pc-windows-msvc.zip" + $DB_SOURCE = "qdrant.exe" + $DB_TARGET = "qdrant-$($env:RUST_TARGET).exe" + } + "win-arm64" { + $QDRANT_FILE = "x86_64-pc-windows-msvc.zip" + $DB_SOURCE = "qdrant.exe" + $DB_TARGET = "qdrant-$($env:RUST_TARGET).exe" + } + default { + Write-Error "Unknown platform: $($env:DOTNET_RUNTIME)" + exit 1 + } + } + + $QDRANT_URL = "https://github.com/qdrant/qdrant/releases/download/$($env:QDRANT_VERSION)/qdrant-$QDRANT_FILE" + Write-Host "Download $QDRANT_URL ..." + + # Create a unique temporary directory (not just a file) + $TMP = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $TMP -Force | Out-Null + $ARCHIVE = Join-Path $TMP "qdrant.tgz" + + Invoke-WebRequest -Uri $QDRANT_URL -OutFile $ARCHIVE + + Write-Host "Extracting Qdrant ..." + tar -xzf $ARCHIVE -C $TMP + + $SRC = Join-Path $TMP $DB_SOURCE + if (!(Test-Path $SRC)) { + Write-Error "Cannot find Qdrant source: $SRC" + exit 1 + } + + $DEST = Join-Path $TDB_DIR $DB_TARGET + Copy-Item -Path $SRC -Destination $DEST -Force + + Write-Host "Cleaning up ..." + Remove-Item $ARCHIVE -Force -ErrorAction SilentlyContinue + # Try to remove the temporary directory, but ignore errors if files are still in use try { Remove-Item $TMP -Recurse -Force -ErrorAction Stop @@ -821,4 +953,4 @@ jobs: name: "Release ${{ env.FORMATTED_VERSION }}" fail_on_unmatched_files: true files: | - release/assets/* \ No newline at end of file + release/assets/* diff --git a/.gitignore b/.gitignore index abf6df5f..3175fdb1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ libpdfium.dylib libpdfium.so libpdfium.dll +# Ignore qdrant database: +qdrant-aarch64-apple-darwin +qdrant-x86_64-apple-darwin +qdrant-aarch64-unknown-linux-gnu +qdrant-x86_64-unknown-linux-gnu +qdrant-x86_64-pc-windows-msvc.exe + # User-specific files *.rsuser *.suo diff --git a/README.md b/README.md index 30536bcd..363ec8aa 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Since November 2024: Work on RAG (integration of your data and files) has begun. - [x] ~~App: Implement dialog for checking & handling [pandoc](https://pandoc.org/) installation ([PR #393](https://github.com/MindWorkAI/AI-Studio/pull/393), [PR #487](https://github.com/MindWorkAI/AI-Studio/pull/487))~~ - [ ] App: Implement external embedding providers - [ ] App: Implement the process to vectorize one local file using embeddings -- [ ] Runtime: Integration of the vector database [Qdrant](https://github.com/qdrant/qdrant) +- [x] ~~Runtime: Integration of the vector database [Qdrant](https://github.com/qdrant/qdrant) ([PR #580](https://github.com/MindWorkAI/AI-Studio/pull/580))~~ - [ ] App: Implement the continuous process of vectorizing data - [x] ~~App: Define a common retrieval context interface for the integration of RAG processes in chats (PR [#281](https://github.com/MindWorkAI/AI-Studio/pull/281), [#284](https://github.com/MindWorkAI/AI-Studio/pull/284), [#286](https://github.com/MindWorkAI/AI-Studio/pull/286), [#287](https://github.com/MindWorkAI/AI-Studio/pull/287))~~ - [x] ~~App: Define a common augmentation interface for the integration of RAG processes in chats (PR [#288](https://github.com/MindWorkAI/AI-Studio/pull/288), [#289](https://github.com/MindWorkAI/AI-Studio/pull/289))~~ diff --git a/app/Build/Commands/Database.cs b/app/Build/Commands/Database.cs new file mode 100644 index 00000000..dcd78391 --- /dev/null +++ b/app/Build/Commands/Database.cs @@ -0,0 +1,3 @@ +namespace Build.Commands; + +public record Database(string Path, string Filename); \ No newline at end of file diff --git a/app/Build/Commands/Qdrant.cs b/app/Build/Commands/Qdrant.cs new file mode 100644 index 00000000..29369ccf --- /dev/null +++ b/app/Build/Commands/Qdrant.cs @@ -0,0 +1,120 @@ +using System.Formats.Tar; +using System.IO.Compression; + +using SharedTools; + +namespace Build.Commands; + +public static class Qdrant +{ + public static async Task InstallAsync(RID rid, string version) + { + Console.Write($"- Installing Qdrant {version} for {rid.ToUserFriendlyName()} ..."); + + var cwd = Environment.GetRustRuntimeDirectory(); + var qdrantTmpDownloadPath = Path.GetTempFileName(); + var qdrantTmpExtractPath = Directory.CreateTempSubdirectory(); + var qdrantUrl = GetQdrantDownloadUrl(rid, version); + + // + // Download the file: + // + Console.Write(" downloading ..."); + using (var client = new HttpClient()) + { + var response = await client.GetAsync(qdrantUrl); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($" failed to download Qdrant {version} for {rid.ToUserFriendlyName()} from {qdrantUrl}"); + return; + } + + await using var fileStream = File.Create(qdrantTmpDownloadPath); + await response.Content.CopyToAsync(fileStream); + } + + // + // Extract the downloaded file: + // + Console.Write(" extracting ..."); + await using(var zStream = File.Open(qdrantTmpDownloadPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (rid == RID.WIN_X64) + { + using var archive = new ZipArchive(zStream, ZipArchiveMode.Read); + archive.ExtractToDirectory(qdrantTmpExtractPath.FullName, overwriteFiles: true); + } + else + { + await using var uncompressedStream = new GZipStream(zStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(uncompressedStream, qdrantTmpExtractPath.FullName, true); + } + } + + // + // Copy the database to the target directory: + // + Console.Write(" deploying ..."); + var database = GetDatabasePath(rid); + if (string.IsNullOrWhiteSpace(database.Path)) + { + Console.WriteLine($" failed to find the database path for {rid.ToUserFriendlyName()}"); + return; + } + + var qdrantDbSourcePath = Path.Join(qdrantTmpExtractPath.FullName, database.Path); + var qdrantDbTargetPath = Path.Join(cwd, "target", "databases", "qdrant",database.Filename); + if (!File.Exists(qdrantDbSourcePath)) + { + Console.WriteLine($" failed to find the database file '{qdrantDbSourcePath}'"); + return; + } + + Directory.CreateDirectory(Path.Join(cwd, "target", "databases", "qdrant")); + if (File.Exists(qdrantDbTargetPath)) + File.Delete(qdrantDbTargetPath); + + File.Copy(qdrantDbSourcePath, qdrantDbTargetPath); + + // + // Cleanup: + // + Console.Write(" cleaning up ..."); + File.Delete(qdrantTmpDownloadPath); + Directory.Delete(qdrantTmpExtractPath.FullName, true); + + Console.WriteLine(" done."); + } + + private static Database GetDatabasePath(RID rid) => rid switch + { + RID.OSX_ARM64 => new("qdrant", "qdrant-aarch64-apple-darwin"), + RID.OSX_X64 => new("qdrant", "qdrant-x86_64-apple-darwin"), + + RID.LINUX_ARM64 => new("qdrant", "qdrant-aarch64-unknown-linux-gnu"), + RID.LINUX_X64 => new("qdrant", "qdrant-x86_64-unknown-linux-gnu"), + + RID.WIN_X64 => new("qdrant.exe", "qdrant-x86_64-pc-windows-msvc.exe"), + RID.WIN_ARM64 => new("qdrant.exe", "qdrant-aarch64-pc-windows-msvc.exe"), + + _ => new(string.Empty, string.Empty), + }; + + private static string GetQdrantDownloadUrl(RID rid, string version) + { + var baseUrl = $"https://github.com/qdrant/qdrant/releases/download/v{version}/qdrant-"; + return rid switch + { + RID.LINUX_ARM64 => $"{baseUrl}aarch64-unknown-linux-musl.tar.gz", + RID.LINUX_X64 => $"{baseUrl}x86_64-unknown-linux-gnu.tar.gz", + + RID.OSX_ARM64 => $"{baseUrl}aarch64-apple-darwin.tar.gz", + RID.OSX_X64 => $"{baseUrl}x86_64-apple-darwin.tar.gz", + + RID.WIN_X64 => $"{baseUrl}x86_64-pc-windows-msvc.zip", + RID.WIN_ARM64 => $"{baseUrl}x86_64-pc-windows-msvc.zip", + + _ => string.Empty, + }; + } +} \ No newline at end of file diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 5ad74591..5ec929ab 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -150,6 +150,9 @@ public sealed partial class UpdateMetadataCommands var pdfiumVersion = await this.ReadPdfiumVersion(); await Pdfium.InstallAsync(rid, pdfiumVersion); + + var qdrantVersion = await this.ReadQdrantVersion(); + await Qdrant.InstallAsync(rid, qdrantVersion); Console.Write($"- Start .NET build for {rid.ToUserFriendlyName()} ..."); await this.ReadCommandOutput(pathApp, "dotnet", $"clean --configuration release --runtime {rid.AsMicrosoftRid()}"); @@ -364,6 +367,16 @@ public sealed partial class UpdateMetadataCommands return shortVersion; } + private async Task ReadQdrantVersion() + { + const int QDRANT_VERSION_INDEX = 11; + var pathMetadata = Environment.GetMetadataPath(); + var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); + var currentQdrantVersion = lines[QDRANT_VERSION_INDEX].Trim(); + + return currentQdrantVersion; + } + private async Task UpdateArchitecture(RID rid) { const int ARCHITECTURE_INDEX = 9; diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index 86bd9eb3..51ce5109 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -27,4 +27,6 @@ True True True + True + True True \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index a3f56347..f825d229 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5011,9 +5011,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs w -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." +-- Database version +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version" + -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." + -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T162898512"] = "We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library." @@ -5050,6 +5056,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." +-- Copies the following to the clipboard +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the following to the clipboard" + -- Copies the server URL to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the server URL to the clipboard" @@ -5095,6 +5104,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source -- Build time UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time" +-- This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2619858133"] = "This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant." + -- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems." @@ -5212,6 +5224,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library -- Used .NET SDK UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK" +-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated." + -- Did you find a bug or are you experiencing issues? Report your concern here. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T639371534"] = "Did you find a bug or are you experiencing issues? Report your concern here." diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index 9e49b98b..0d668b8b 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -52,6 +52,7 @@ + @@ -81,6 +82,7 @@ $([System.String]::Copy( $(Metadata) ).Split( ';' )[ 8 ]) $([System.String]::Copy( $(Metadata) ).Split( ';' )[ 9 ]) $([System.String]::Copy( $(Metadata) ).Split( ';' )[ 10 ]) + $([System.String]::Copy( $(Metadata) ).Split( ';' )[ 11 ]) true @@ -108,6 +110,9 @@ <_Parameter1>$(MetaPdfiumVersion) + + <_Parameter1>$(MetaQdrantVersion) + diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 13b7598d..72673982 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -19,6 +19,29 @@ + + + @this.VersionDatabase + + + + @foreach (var item in this.databaseDisplayInfo) + { +
+ + @item.Label: @item.Value + +
+ } +
+
+ + @(this.showDatabaseDetails ? T("Hide Details") : T("Show Details")) + +
@@ -194,6 +217,7 @@ + @@ -214,6 +238,8 @@ + + diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 09510d27..aa649a3c 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -2,6 +2,7 @@ using System.Reflection; using AIStudio.Components; using AIStudio.Dialogs; +using AIStudio.Tools.Databases; using AIStudio.Tools.Metadata; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; @@ -25,12 +26,16 @@ public partial class Information : MSGComponentBase [Inject] private ISnackbar Snackbar { get; init; } = null!; - + + [Inject] + private DatabaseClient DatabaseClient { get; init; } = null!; + private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; private static readonly MetaDataArchitectureAttribute META_DATA_ARCH = ASSEMBLY.GetCustomAttribute()!; private static readonly MetaDataLibrariesAttribute META_DATA_LIBRARIES = ASSEMBLY.GetCustomAttribute()!; - + private static readonly MetaDataDatabasesAttribute META_DATA_DATABASES = ASSEMBLY.GetCustomAttribute()!; + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(Information).Namespace, nameof(Information)); private string osLanguage = string.Empty; @@ -53,6 +58,8 @@ public partial class Information : MSGComponentBase private string VersionPdfium => $"{T("Used PDFium version")}: v{META_DATA_LIBRARIES.PdfiumVersion}"; + private string VersionDatabase => $"{T("Database version")}: {this.DatabaseClient.Name} v{META_DATA_DATABASES.DatabaseVersion}"; + private string versionPandoc = TB("Determine Pandoc version, please wait..."); private PandocInstallation pandocInstallation; @@ -60,7 +67,13 @@ public partial class Information : MSGComponentBase private bool showEnterpriseConfigDetails; + private bool showDatabaseDetails; + private IPluginMetadata? configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION); + + private sealed record DatabaseDisplayInfo(string Label, string Value); + + private readonly List databaseDisplayInfo = new(); /// /// Determines whether the enterprise configuration has details that can be shown/hidden. @@ -96,6 +109,11 @@ public partial class Information : MSGComponentBase this.osLanguage = await this.RustService.ReadUserLanguage(); this.logPaths = await this.RustService.GetLogPaths(); + await foreach (var (label, value) in this.DatabaseClient.GetDisplayInfo()) + { + this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value)); + } + // Determine the Pandoc version may take some time, so we start it here // without waiting for the result: _ = this.DeterminePandocVersion(); @@ -170,6 +188,11 @@ public partial class Information : MSGComponentBase { this.showEnterpriseConfigDetails = !this.showEnterpriseConfigDetails; } + + private void ToggleDatabaseDetails() + { + this.showDatabaseDetails = !this.showDatabaseDetails; + } private async Task CopyStartupLogPath() { 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 6adbe50f..5f4adf5d 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 @@ -5013,9 +5013,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio läuft -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "Diese Bibliothek wird verwendet, um PDF-Dateien zu lesen. Das ist zum Beispiel notwendig, um PDFs als Datenquelle für einen Chat zu nutzen." +-- Database version +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Datenbankversion" + -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "Diese Bibliothek wird verwendet, um die MudBlazor-Bibliothek zu erweitern. Sie stellt zusätzliche Komponenten bereit, die nicht Teil der MudBlazor-Bibliothek sind." +-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant ist eine Vektordatenbank und Suchmaschine für Vektoren. Wir nutzen Qdrant, um lokales RAG (Retrieval-Augmented Generation) innerhalb von AI Studio zu realisieren. Vielen Dank für den Einsatz und die großartige Arbeit, die in Qdrant gesteckt wurde und weiterhin gesteckt wird." + -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T162898512"] = "Wir verwenden Lua als Sprache für Plugins. Lua-CSharp ermöglicht die Kommunikation zwischen Lua-Skripten und AI Studio in beide Richtungen. Vielen Dank an Yusuke Nakada für diese großartige Bibliothek." @@ -5052,6 +5058,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "Wir verwenden Rocket zur Implementierung der Runtime-API. Dies ist notwendig, da die Runtime mit der Benutzeroberfläche (IPC) kommunizieren muss. Rocket ist ein ausgezeichnetes Framework zur Umsetzung von Web-APIs in Rust." +-- Copies the following to the clipboard +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Kopiert Folgendes in die Zwischenablage" + -- Copies the server URL to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Kopiert die Server-URL in die Zwischenablage" @@ -5097,6 +5106,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Verwendete Open- -- Build time UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build-Zeit" +-- This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2619858133"] = "Diese Bibliothek wird verwendet, um temporäre Ordner zu erstellen, in denen das Zertifikat und der private Schlüssel für die Kommunikation mit Qdrant gespeichert werden." + -- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "Dieses Crate stellt Derive-Makros für Rust-Enums bereit, die wir verwenden, um Boilerplate zu reduzieren, wenn wir String-Konvertierungen und Metadaten für Laufzeittypen implementieren. Das ist hilfreich für die Kommunikation zwischen unseren Rust- und .NET-Systemen." @@ -5214,6 +5226,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "Dies ist eine Bib -- Used .NET SDK UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Verwendetes .NET SDK" +-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "Diese Bibliothek wird verwendet, um Sidecar-Prozesse zu verwalten und sicherzustellen, dass veraltete oder Zombie-Sidecars erkannt und beendet werden." + -- Did you find a bug or are you experiencing issues? Report your concern here. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T639371534"] = "Haben Sie einen Fehler gefunden oder Probleme festgestellt? Melden Sie Ihr Anliegen hier." 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 5c164305..5a4f9f78 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 @@ -5013,9 +5013,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs w -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." +-- Database version +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version" + -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." + -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T162898512"] = "We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library." @@ -5052,6 +5058,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." +-- Copies the following to the clipboard +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the following to the clipboard" + -- Copies the server URL to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the server URL to the clipboard" @@ -5097,6 +5106,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source -- Build time UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time" +-- This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2619858133"] = "This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant." + -- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems." @@ -5214,6 +5226,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library -- Used .NET SDK UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK" +-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated." + -- Did you find a bug or are you experiencing issues? Report your concern here. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T639371534"] = "Did you find a bug or are you experiencing issues? Report your concern here." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 9c0e2bcd..85e97b07 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -1,5 +1,7 @@ using AIStudio.Agents; using AIStudio.Settings; +using AIStudio.Tools.Databases; +using AIStudio.Tools.Databases.Qdrant; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; @@ -24,6 +26,7 @@ internal sealed class Program public static string API_TOKEN = null!; public static IServiceProvider SERVICE_PROVIDER = null!; public static ILoggerFactory LOGGER_FACTORY = null!; + public static DatabaseClient DATABASE_CLIENT = null!; public static async Task Main() { @@ -82,6 +85,39 @@ internal sealed class Program return; } + var qdrantInfo = await rust.GetQdrantInfo(); + if (qdrantInfo.Path == string.Empty) + { + Console.WriteLine("Error: Failed to get the Qdrant path from Rust."); + return; + } + + if (qdrantInfo.PortHttp == 0) + { + Console.WriteLine("Error: Failed to get the Qdrant HTTP port from Rust."); + return; + } + + if (qdrantInfo.PortGrpc == 0) + { + Console.WriteLine("Error: Failed to get the Qdrant gRPC port from Rust."); + return; + } + + if (qdrantInfo.Fingerprint == string.Empty) + { + Console.WriteLine("Error: Failed to get the Qdrant fingerprint from Rust."); + return; + } + + if (qdrantInfo.ApiToken == string.Empty) + { + Console.WriteLine("Error: Failed to get the Qdrant API token from Rust."); + return; + } + + var databaseClient = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken); + var builder = WebApplication.CreateBuilder(); builder.WebHost.ConfigureKestrel(kestrelServerOptions => { @@ -133,6 +169,7 @@ internal sealed class Program builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddSingleton(databaseClient); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -191,6 +228,10 @@ internal sealed class Program RUST_SERVICE = rust; ENCRYPTION = encryption; + + var databaseLogger = app.Services.GetRequiredService>(); + databaseClient.SetLogger(databaseLogger); + DATABASE_CLIENT = databaseClient; programLogger.LogInformation("Initialize internal file system."); app.Use(Redirect.HandlerContentAsync); @@ -228,6 +269,7 @@ internal sealed class Program await serverTask; RUST_SERVICE.Dispose(); + DATABASE_CLIENT.Dispose(); PluginFactory.Dispose(); programLogger.LogInformation("The AI Studio server was stopped."); } diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs new file mode 100644 index 00000000..5ea457ec --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs @@ -0,0 +1,52 @@ +namespace AIStudio.Tools.Databases; + +public abstract class DatabaseClient(string name, string path) +{ + public string Name => name; + + private string Path => path; + + protected ILogger? logger; + + public abstract IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo(); + + public string GetStorageSize() + { + if (string.IsNullOrWhiteSpace(this.Path)) + { + this.logger!.LogError($"Error: Database path '{this.Path}' cannot be null or empty."); + return "0 B"; + } + + if (!Directory.Exists(this.Path)) + { + this.logger!.LogError($"Error: Database path '{this.Path}' does not exist."); + return "0 B"; + } + var files = Directory.EnumerateFiles(this.Path, "*", SearchOption.AllDirectories) + .Where(file => !System.IO.Path.GetDirectoryName(file)!.Contains("cert", StringComparison.OrdinalIgnoreCase)); + var size = files.Sum(file => new FileInfo(file).Length); + return FormatBytes(size); + } + + public static string FormatBytes(long size) + { + string[] suffixes = { "B", "KB", "MB", "GB", "TB", "PB" }; + int suffixIndex = 0; + + while (size >= 1024 && suffixIndex < suffixes.Length - 1) + { + size /= 1024; + suffixIndex++; + } + + return $"{size:0##} {suffixes[suffixIndex]}"; + } + + public void SetLogger(ILogger logService) + { + this.logger = logService; + } + + public abstract void Dispose(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs new file mode 100644 index 00000000..77ae3636 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs @@ -0,0 +1,66 @@ +using Qdrant.Client; +using Qdrant.Client.Grpc; + +namespace AIStudio.Tools.Databases.Qdrant; + +public class QdrantClientImplementation : DatabaseClient +{ + private int HttpPort { get; } + + private int GrpcPort { get; } + + private QdrantClient GrpcClient { get; } + + private string Fingerprint { get; } + + private string ApiToken { get; } + + public QdrantClientImplementation(string name, string path, int httpPort, int grpcPort, string fingerprint, string apiToken): base(name, path) + { + this.HttpPort = httpPort; + this.GrpcPort = grpcPort; + this.Fingerprint = fingerprint; + this.ApiToken = apiToken; + this.GrpcClient = this.CreateQdrantClient(); + } + + private const string IP_ADDRESS = "localhost"; + + public QdrantClient CreateQdrantClient() + { + var address = "https://" + IP_ADDRESS + ":" + this.GrpcPort; + var channel = QdrantChannel.ForAddress(address, new ClientConfiguration + { + ApiKey = this.ApiToken, + CertificateThumbprint = this.Fingerprint + }); + var grpcClient = new QdrantGrpcClient(channel); + return new QdrantClient(grpcClient); + } + + public async Task GetVersion() + { + var operation = await this.GrpcClient.HealthAsync(); + return "v"+operation.Version; + } + + public async Task GetCollectionsAmount() + { + var operation = await this.GrpcClient.ListCollectionsAsync(); + return operation.Count.ToString(); + } + + public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() + { + yield return ("HTTP port", this.HttpPort.ToString()); + yield return ("gRPC port", this.GrpcPort.ToString()); + yield return ("Extracted version", await this.GetVersion()); + yield return ("Storage size", $"{base.GetStorageSize()}"); + yield return ("Amount of collections", await this.GetCollectionsAmount()); + } + + public override void Dispose() + { + this.GrpcClient.Dispose(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs b/app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs new file mode 100644 index 00000000..5ef6064b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Metadata/MetaDataDatabasesAttribute.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Metadata; + +public class MetaDataDatabasesAttribute(string databaseVersion) : Attribute +{ + public string DatabaseVersion => databaseVersion; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs new file mode 100644 index 00000000..c847235f --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs @@ -0,0 +1,17 @@ +namespace AIStudio.Tools.Rust; + +/// +/// The response of the Qdrant information request. +/// +public readonly record struct QdrantInfo +{ + public string Path { get; init; } + + public int PortHttp { get; init; } + + public int PortGrpc { get; init; } + + public string Fingerprint { get; init; } + + public string ApiToken { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs new file mode 100644 index 00000000..a43f6c61 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -0,0 +1,25 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +public sealed partial class RustService +{ + public async Task GetQdrantInfo() + { + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); + var response = await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); + return response; + } + catch (Exception e) + { + if(this.logger is not null) + this.logger.LogError(e, "Error while fetching Qdrant info from Rust service."); + else + Console.WriteLine($"Error while fetching Qdrant info from Rust service: '{e}'."); + + return default; + } + } +} \ 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 23c3747c..e4b9137b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -1 +1,2 @@ # v26.2.2, build 234 (2026-02-xx xx:xx UTC) +- Added a vector database (Qdrant) as a building block for our local RAG (retrieval-augmented generation) solution. Thank you very much, Paul (`PaulKoudelka`), for this major contribution. Note that our local RAG implementation remained in preview and has not yet been released; other building blocks are not yet ready. \ No newline at end of file diff --git a/documentation/Build.md b/documentation/Build.md index 21063eef..600999fe 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -45,7 +45,17 @@ Do you want to test your changes before creating a PR? Follow these steps: 9. Execute the command `dotnet run`. 10. After compiling the .NET code, the app will finally start inside the Tauri runtime window. -You can now test your changes. +You can now test your changes. To stop the application: +- Close the Tauri window (GUI). +- Press ``Ctrl+C`` in the terminal where the app is running. +- Stop the process via your IDE’s run/debug controls. + +> ⚠️ Important: Stopping the app via ``Ctrl+C`` or the IDE may not terminate the Qdrant sidecar process, especially on Windows. This can lead to startup failures when restarting the app. + +If you encounter issues with restarting Tauri, then manually kill the Qdrant process: +- **Linux/macOS:** Run pkill -f qdrant in your terminal. +- **Windows:** Open Task Manager → Find qdrant.exe → Right-click → “End task”. +- Restart your Tauri app. ## Create a release In order to create a release: diff --git a/metadata.txt b/metadata.txt index fa80ecb5..fe984c68 100644 --- a/metadata.txt +++ b/metadata.txt @@ -8,4 +8,5 @@ 1.8.1 8f9cd40d060, release osx-arm64 -144.0.7543.0 \ No newline at end of file +144.0.7543.0 +1.16.3 \ No newline at end of file diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index c647be37..b7a4bac0 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -39,13 +39,17 @@ pdfium-render = "0.8.37" sys-locale = "0.3.2" cfg-if = "1.0.4" pptx-to-md = "0.4.0" +tempfile = "3.8" strum_macros = "0.27" +sysinfo = "0.38.0" # Fixes security vulnerability downstream, where the upstream is not fixed yet: url = "2.5.8" ring = "0.17.14" crossbeam-channel = "0.5.15" -tracing-subscriber = "0.3.22" +tracing-subscriber = "0.3.20" +dirs = "6.0.0" + [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 diff --git a/runtime/build.rs b/runtime/build.rs index 93871d1a..c4d1f749 100644 --- a/runtime/build.rs +++ b/runtime/build.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{PathBuf}; fn main() { tauri_build::build(); @@ -83,4 +83,4 @@ fn update_tauri_conf(tauri_conf_path: &str, version: &str) { } std::fs::write(tauri_conf_path, new_tauri_conf).unwrap(); -} +} \ No newline at end of file diff --git a/runtime/resources/databases/qdrant/config.yaml b/runtime/resources/databases/qdrant/config.yaml new file mode 100644 index 00000000..50f03e08 --- /dev/null +++ b/runtime/resources/databases/qdrant/config.yaml @@ -0,0 +1,354 @@ +log_level: INFO + +# Logging configuration +# Qdrant logs to stdout. You may configure to also write logs to a file on disk. +# Be aware that this file may grow indefinitely. +# logger: +# # Logging format, supports `text` and `json` +# format: text +# on_disk: +# enabled: true +# log_file: path/to/log/file.log +# log_level: INFO +# # Logging format, supports `text` and `json` +# format: text +# buffer_size_bytes: 1024 + +storage: + + snapshots_config: + # "local" or "s3" - where to store snapshots + snapshots_storage: local + # s3_config: + # bucket: "" + # region: "" + # access_key: "" + # secret_key: "" + + # Where to store temporary files + # If null, temporary snapshots are stored in: storage/snapshots_temp/ + temp_path: null + + # If true - point payloads will not be stored in memory. + # It will be read from the disk every time it is requested. + # This setting saves RAM by (slightly) increasing the response time. + # Note: those payload values that are involved in filtering and are indexed - remain in RAM. + # + # Default: true + on_disk_payload: true + + # Maximum number of concurrent updates to shard replicas + # If `null` - maximum concurrency is used. + update_concurrency: null + + # Write-ahead-log related configuration + wal: + # Size of a single WAL segment + wal_capacity_mb: 32 + + # Number of WAL segments to create ahead of actual data requirement + wal_segments_ahead: 0 + + # Normal node - receives all updates and answers all queries + node_type: "Normal" + + # Listener node - receives all updates, but does not answer search/read queries + # Useful for setting up a dedicated backup node + # node_type: "Listener" + + performance: + # Number of parallel threads used for search operations. If 0 - auto selection. + max_search_threads: 0 + + # CPU budget, how many CPUs (threads) to allocate for an optimization job. + # If 0 - auto selection, keep 1 or more CPUs unallocated depending on CPU size + # If negative - subtract this number of CPUs from the available CPUs. + # If positive - use this exact number of CPUs. + optimizer_cpu_budget: 0 + + # Prevent DDoS of too many concurrent updates in distributed mode. + # One external update usually triggers multiple internal updates, which breaks internal + # timings. For example, the health check timing and consensus timing. + # If null - auto selection. + update_rate_limit: null + + # Limit for number of incoming automatic shard transfers per collection on this node, does not affect user-requested transfers. + # The same value should be used on all nodes in a cluster. + # Default is to allow 1 transfer. + # If null - allow unlimited transfers. + #incoming_shard_transfers_limit: 1 + + # Limit for number of outgoing automatic shard transfers per collection on this node, does not affect user-requested transfers. + # The same value should be used on all nodes in a cluster. + # Default is to allow 1 transfer. + # If null - allow unlimited transfers. + #outgoing_shard_transfers_limit: 1 + + # Enable async scorer which uses io_uring when rescoring. + # Only supported on Linux, must be enabled in your kernel. + # See: + #async_scorer: false + + optimizers: + # The minimal fraction of deleted vectors in a segment, required to perform segment optimization + deleted_threshold: 0.2 + + # The minimal number of vectors in a segment, required to perform segment optimization + vacuum_min_vector_number: 1000 + + # Target amount of segments optimizer will try to keep. + # Real amount of segments may vary depending on multiple parameters: + # - Amount of stored points + # - Current write RPS + # + # It is recommended to select default number of segments as a factor of the number of search threads, + # so that each segment would be handled evenly by one of the threads. + # If `default_segment_number = 0`, will be automatically selected by the number of available CPUs + default_segment_number: 0 + + # Do not create segments larger this size (in KiloBytes). + # Large segments might require disproportionately long indexation times, + # therefore it makes sense to limit the size of segments. + # + # If indexation speed have more priority for your - make this parameter lower. + # If search speed is more important - make this parameter higher. + # Note: 1Kb = 1 vector of size 256 + # If not set, will be automatically selected considering the number of available CPUs. + max_segment_size_kb: null + + # Maximum size (in KiloBytes) of vectors allowed for plain index. + # Default value based on experiments and observations. + # Note: 1Kb = 1 vector of size 256 + # To explicitly disable vector indexing, set to `0`. + # If not set, the default value will be used. + indexing_threshold_kb: 10000 + + # Interval between forced flushes. + flush_interval_sec: 5 + + # Max number of threads (jobs) for running optimizations per shard. + # Note: each optimization job will also use `max_indexing_threads` threads by itself for index building. + # If null - have no limit and choose dynamically to saturate CPU. + # If 0 - no optimization threads, optimizations will be disabled. + max_optimization_threads: null + + # This section has the same options as 'optimizers' above. All values specified here will overwrite the collections + # optimizers configs regardless of the config above and the options specified at collection creation. + #optimizers_overwrite: + # deleted_threshold: 0.2 + # vacuum_min_vector_number: 1000 + # default_segment_number: 0 + # max_segment_size_kb: null + # indexing_threshold_kb: 10000 + # flush_interval_sec: 5 + # max_optimization_threads: null + + # Default parameters of HNSW Index. Could be overridden for each collection or named vector individually + hnsw_index: + # Number of edges per node in the index graph. Larger the value - more accurate the search, more space required. + m: 16 + + # Number of neighbours to consider during the index building. Larger the value - more accurate the search, more time required to build index. + ef_construct: 100 + + # Minimal size threshold (in KiloBytes) below which full-scan is preferred over HNSW search. + # This measures the total size of vectors being queried against. + # When the maximum estimated amount of points that a condition satisfies is smaller than + # `full_scan_threshold_kb`, the query planner will use full-scan search instead of HNSW index + # traversal for better performance. + # Note: 1Kb = 1 vector of size 256 + full_scan_threshold_kb: 10000 + + # Number of parallel threads used for background index building. + # If 0 - automatically select. + # Best to keep between 8 and 16 to prevent likelihood of building broken/inefficient HNSW graphs. + # On small CPUs, less threads are used. + max_indexing_threads: 0 + + # Store HNSW index on disk. If set to false, index will be stored in RAM. Default: false + on_disk: false + + # Custom M param for hnsw graph built for payload index. If not set, default M will be used. + payload_m: null + + # Default shard transfer method to use if none is defined. + # If null - don't have a shard transfer preference, choose automatically. + # If stream_records, snapshot or wal_delta - prefer this specific method. + # More info: https://qdrant.tech/documentation/guides/distributed_deployment/#shard-transfer-method + shard_transfer_method: null + + # Default parameters for collections + collection: + # Number of replicas of each shard that network tries to maintain + replication_factor: 1 + + # How many replicas should apply the operation for us to consider it successful + write_consistency_factor: 1 + + # Default parameters for vectors. + vectors: + # Whether vectors should be stored in memory or on disk. + on_disk: null + + # shard_number_per_node: 1 + + # Default quantization configuration. + # More info: https://qdrant.tech/documentation/guides/quantization + quantization: null + + # Default strict mode parameters for newly created collections. + #strict_mode: + # Whether strict mode is enabled for a collection or not. + #enabled: false + + # Max allowed `limit` parameter for all APIs that don't have their own max limit. + #max_query_limit: null + + # Max allowed `timeout` parameter. + #max_timeout: null + + # Allow usage of unindexed fields in retrieval based (eg. search) filters. + #unindexed_filtering_retrieve: null + + # Allow usage of unindexed fields in filtered updates (eg. delete by payload). + #unindexed_filtering_update: null + + # Max HNSW value allowed in search parameters. + #search_max_hnsw_ef: null + + # Whether exact search is allowed or not. + #search_allow_exact: null + + # Max oversampling value allowed in search. + #search_max_oversampling: null + + # Maximum number of collections allowed to be created + # If null - no limit. + max_collections: null + +service: + # Maximum size of POST data in a single request in megabytes + max_request_size_mb: 32 + + # Number of parallel workers used for serving the api. If 0 - equal to the number of available cores. + # If missing - Same as storage.max_search_threads + max_workers: 0 + + # Host to bind the service on + host: 127.0.0.1 + + # HTTP(S) port to bind the service on + # http_port: 6333 + + # gRPC port to bind the service on. + # If `null` - gRPC is disabled. Default: null + # Comment to disable gRPC: + # grpc_port: 6334 + + # Enable CORS headers in REST API. + # If enabled, browsers would be allowed to query REST endpoints regardless of query origin. + # More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS + # Default: true + enable_cors: false + + # Enable HTTPS for the REST and gRPC API + # TLS is enabled in AI Studio through environment variables when instantiating Qdrant as a sidecar. + # enable_tls: false + + # Check user HTTPS client certificate against CA file specified in tls config + verify_https_client_certificate: false + + # Set an api-key. + # If set, all requests must include a header with the api-key. + # example header: `api-key: ` + # + # If you enable this you should also enable TLS. + # (Either above or via an external service like nginx.) + # Sending an api-key over an unencrypted channel is insecure. + # + # Uncomment to enable. + # api_key: your_secret_api_key_here + + # Set an api-key for read-only operations. + # If set, all requests must include a header with the api-key. + # example header: `api-key: ` + # + # If you enable this you should also enable TLS. + # (Either above or via an external service like nginx.) + # Sending an api-key over an unencrypted channel is insecure. + # + # Uncomment to enable. + # read_only_api_key: your_secret_read_only_api_key_here + + # Uncomment to enable JWT Role Based Access Control (RBAC). + # If enabled, you can generate JWT tokens with fine-grained rules for access control. + # Use generated token instead of API key. + # + # jwt_rbac: true + + # Hardware reporting adds information to the API responses with a + # hint on how many resources were used to execute the request. + # + # Warning: experimental, this feature is still under development and is not supported yet. + # + # Uncomment to enable. + # hardware_reporting: true + # + # Uncomment to enable. + # Prefix for the names of metrics in the /metrics API. + # metrics_prefix: qdrant_ + +cluster: + # Use `enabled: true` to run Qdrant in distributed deployment mode + enabled: false + + # Configuration of the inter-cluster communication + p2p: + # Port for internal communication between peers + port: 6335 + + # Use TLS for communication between peers + enable_tls: false + + # Configuration related to distributed consensus algorithm + consensus: + # How frequently peers should ping each other. + # Setting this parameter to lower value will allow consensus + # to detect disconnected nodes earlier, but too frequent + # tick period may create significant network and CPU overhead. + # We encourage you NOT to change this parameter unless you know what you are doing. + tick_period_ms: 100 + + # Compact consensus operations once we have this amount of applied + # operations. Allows peers to join quickly with a consensus snapshot without + # replaying a huge amount of operations. + # If 0 - disable compaction + compact_wal_entries: 128 + +# Set to true to prevent service from sending usage statistics to the developers. +# Read more: https://qdrant.tech/documentation/guides/telemetry +telemetry_disabled: true + +# TLS configuration. +# Required if either service.enable_tls or cluster.p2p.enable_tls is true. +tls: + # Server certificate chain file + # cert: ./tls/cert.pem + + # Server private key file + # key: ./tls/key.pem + + # Certificate authority certificate file. + # This certificate will be used to validate the certificates + # presented by other nodes during inter-cluster communication. + # + # If verify_https_client_certificate is true, it will verify + # HTTPS client certificate + # + # Required if cluster.p2p.enable_tls is true. + ca_cert: ./tls/cacert.pem + + # TTL in seconds to reload certificate from disk, useful for certificate rotations. + # Only works for HTTPS endpoints. Does not support gRPC (and intra-cluster communication). + # If `null` - TTL is disabled. + cert_ttl: 3600 \ No newline at end of file diff --git a/runtime/src/api_token.rs b/runtime/src/api_token.rs index 31759185..e945095e 100644 --- a/runtime/src/api_token.rs +++ b/runtime/src/api_token.rs @@ -1,21 +1,5 @@ -use log::info; -use once_cell::sync::Lazy; use rand::{RngCore, SeedableRng}; -use rocket::http::Status; -use rocket::Request; -use rocket::request::FromRequest; - -/// The API token used to authenticate requests. -pub static API_TOKEN: Lazy = Lazy::new(|| { - let mut token = [0u8; 32]; - let mut rng = rand_chacha::ChaChaRng::from_os_rng(); - rng.fill_bytes(&mut token); - - let token = APIToken::from_bytes(token.to_vec()); - info!("API token was generated successfully."); - - token -}); +use rand_chacha::ChaChaRng; /// The API token data structure used to authenticate requests. pub struct APIToken { @@ -34,7 +18,7 @@ impl APIToken { } /// Creates a new API token from a hexadecimal text. - fn from_hex_text(hex_text: &str) -> Self { + pub fn from_hex_text(hex_text: &str) -> Self { APIToken { hex_text: hex_text.to_string(), } @@ -45,40 +29,14 @@ impl APIToken { } /// Validates the received token against the valid token. - fn validate(&self, received_token: &Self) -> bool { + pub fn validate(&self, received_token: &Self) -> bool { received_token.to_hex_text() == self.to_hex_text() } } -/// The request outcome type used to handle API token requests. -type RequestOutcome = rocket::request::Outcome; - -/// The request outcome implementation for the API token. -#[rocket::async_trait] -impl<'r> FromRequest<'r> for APIToken { - type Error = APITokenError; - - /// Handles the API token requests. - async fn from_request(request: &'r Request<'_>) -> RequestOutcome { - let token = request.headers().get_one("token"); - match token { - Some(token) => { - let received_token = APIToken::from_hex_text(token); - if API_TOKEN.validate(&received_token) { - RequestOutcome::Success(received_token) - } else { - RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) - } - } - - None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), - } - } -} - -/// The API token error types. -#[derive(Debug)] -pub enum APITokenError { - Missing, - Invalid, +pub fn generate_api_token() -> APIToken { + let mut token = [0u8; 32]; + let mut rng = ChaChaRng::from_os_rng(); + rng.fill_bytes(&mut token); + APIToken::from_bytes(token.to_vec()) } \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index c69c5c08..c7e7bd74 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -10,15 +10,18 @@ use rocket::serde::Serialize; use serde::Deserialize; use strum_macros::Display; use tauri::updater::UpdateResponse; -use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent}; +use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent, generate_context}; use tauri::api::dialog::blocking::FileDialogBuilder; use tokio::sync::broadcast; use tokio::time; use crate::api_token::APIToken; -use crate::dotnet::stop_dotnet_server; +use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server}; use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY}; use crate::log::switch_to_file_logging; use crate::pdfium::PDFIUM_LIB_PATH; +use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server}; +#[cfg(debug_assertions)] +use crate::dotnet::create_startup_env_file; /// The Tauri main window. static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -101,16 +104,28 @@ pub fn start_tauri() { let data_path = data_path.join("data"); // Get and store the data and config directories: - DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap(); + DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap(); CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); + cleanup_qdrant(); + cleanup_dotnet_server(); + + if is_dev() { + #[cfg(debug_assertions)] + create_startup_env_file(); + } else { + start_dotnet_server(); + } + start_qdrant_server(); + info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}"); switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap(); set_pdfium_path(app.path_resolver()); + Ok(()) }) .plugin(tauri_plugin_window_state::Builder::default().build()) - .build(tauri::generate_context!()) + .build(generate_context!()) .expect("Error while running Tauri application"); // The app event handler: @@ -155,6 +170,7 @@ pub fn start_tauri() { if is_prod() { stop_dotnet_server(); + stop_qdrant_server(); } else { warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); } @@ -183,6 +199,11 @@ pub fn start_tauri() { RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); + stop_qdrant_server(); + if is_prod() { + warn!("Try to stop the .NET server as well..."); + stop_dotnet_server(); + } } RunEvent::Ready => { @@ -194,10 +215,6 @@ pub fn start_tauri() { }); warn!(Source = "Tauri"; "Tauri app was stopped."); - if is_prod() { - warn!("Try to stop the .NET server as well..."); - stop_dotnet_server(); - } } /// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client. diff --git a/runtime/src/certificate.rs b/runtime/src/certificate.rs deleted file mode 100644 index 8cf7fb38..00000000 --- a/runtime/src/certificate.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::sync::OnceLock; -use log::info; -use rcgen::generate_simple_self_signed; -use sha2::{Sha256, Digest}; - -/// The certificate used for the runtime API server. -pub static CERTIFICATE: OnceLock> = OnceLock::new(); - -/// The private key used for the certificate of the runtime API server. -pub static CERTIFICATE_PRIVATE_KEY: OnceLock> = OnceLock::new(); - -/// The fingerprint of the certificate used for the runtime API server. -pub static CERTIFICATE_FINGERPRINT: OnceLock = OnceLock::new(); - -/// Generates a TLS certificate for the runtime API server. -pub fn generate_certificate() { - - info!("Try to generate a TLS certificate for the runtime API server..."); - - let subject_alt_names = vec!["localhost".to_string()]; - let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap(); - let certificate_binary_data = certificate_data.cert.der().to_vec(); - - let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec(); - let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| { - result.push_str(&format!("{:02x}", byte)); - result - }); - - let certificate_fingerprint = certificate_fingerprint.to_uppercase(); - - CERTIFICATE_FINGERPRINT.set(certificate_fingerprint.clone()).expect("Could not set the certificate fingerprint."); - CERTIFICATE.set(certificate_data.cert.pem().as_bytes().to_vec()).expect("Could not set the certificate."); - CERTIFICATE_PRIVATE_KEY.set(certificate_data.signing_key.serialize_pem().as_bytes().to_vec()).expect("Could not set the private key."); - - info!("Certificate fingerprint: '{certificate_fingerprint}'."); - info!("Done generating certificate for the runtime API server."); -} \ No newline at end of file diff --git a/runtime/src/certificate_factory.rs b/runtime/src/certificate_factory.rs new file mode 100644 index 00000000..c7dad76e --- /dev/null +++ b/runtime/src/certificate_factory.rs @@ -0,0 +1,32 @@ +use log::info; +use rcgen::generate_simple_self_signed; +use sha2::{Sha256, Digest}; + +pub struct Certificate { + pub certificate: Vec, + pub private_key: Vec, + pub fingerprint: String, +} + +pub fn generate_certificate() -> Certificate { + + let subject_alt_names = vec!["localhost".to_string()]; + let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap(); + let certificate_binary_data = certificate_data.cert.der().to_vec(); + + let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec(); + let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| { + result.push_str(&format!("{:02x}", byte)); + result + }); + + let certificate_fingerprint = certificate_fingerprint.to_uppercase(); + + info!("Certificate fingerprint: '{certificate_fingerprint}'."); + + Certificate { + certificate: certificate_data.cert.pem().as_bytes().to_vec(), + private_key: certificate_data.signing_key.serialize_pem().as_bytes().to_vec(), + fingerprint: certificate_fingerprint.clone() + } +} \ No newline at end of file diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 5d3f4d71..338074a0 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, Mutex}; use base64::Engine; use base64::prelude::BASE64_STANDARD; @@ -7,13 +8,16 @@ use once_cell::sync::Lazy; use rocket::get; use tauri::api::process::{Command, CommandChild, CommandEvent}; use tauri::Url; -use crate::api_token::{APIToken, API_TOKEN}; +use crate::api_token::APIToken; +use crate::runtime_api_token::API_TOKEN; use crate::app_window::change_location_to; -use crate::certificate::CERTIFICATE_FINGERPRINT; +use crate::runtime_certificate::CERTIFICATE_FINGERPRINT; use crate::encryption::ENCRYPTION; -use crate::environment::is_dev; +use crate::environment::{is_dev, DATA_DIRECTORY}; use crate::network::get_available_port; use crate::runtime_api::API_SERVER_PORT; +use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process}; +use crate::sidecar_types::SidecarType; // The .NET server is started in a separate process and communicates with this // runtime process via IPC. However, we do net start the .NET server in @@ -26,6 +30,9 @@ static DOTNET_SERVER_PORT: Lazy = Lazy::new(|| get_available_port().unwrap( 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; + /// 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")] @@ -93,9 +100,9 @@ pub fn start_dotnet_server() { .envs(dotnet_server_environment) .spawn() .expect("Failed to spawn .NET server process."); - let server_pid = child.pid(); info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); + log_potential_stale_process(Path::new(DATA_DIRECTORY.get().unwrap()).join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); // Save the server process to stop it later: *server_spawn_clone.lock().unwrap() = Some(child); @@ -108,6 +115,7 @@ pub fn start_dotnet_server() { info!(Source = ".NET Server (stdout)"; "{line}"); } }); + } /// This endpoint is called by the .NET server to signal that the server is ready. @@ -152,4 +160,14 @@ pub fn stop_dotnet_server() { } else { warn!("The .NET server process was not started or is already stopped."); } + info!("Start dotnet server cleanup"); + cleanup_dotnet_server(); +} + +/// Remove old Pid files and kill the corresponding processes +pub fn cleanup_dotnet_server() { + let pid_path = Path::new(DATA_DIRECTORY.get().unwrap()).join(PID_FILE_NAME); + if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) { + warn!(Source = ".NET"; "Error during the cleanup of .NET: {}", e); + } } \ No newline at end of file diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 7868a7a4..1b13e099 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -8,8 +8,13 @@ pub mod app_window; pub mod secret; pub mod clipboard; pub mod runtime_api; -pub mod certificate; +pub mod runtime_certificate; pub mod file_data; pub mod metadata; pub mod pdfium; -pub mod pandoc; \ No newline at end of file +pub mod pandoc; +pub mod qdrant; +pub mod certificate_factory; +pub mod runtime_api_token; +pub mod stale_process_cleanup; +mod sidecar_types; \ No newline at end of file diff --git a/runtime/src/main.rs b/runtime/src/main.rs index a66ee287..00a7ba90 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -6,15 +6,12 @@ extern crate core; use log::{info, warn}; use mindwork_ai_studio::app_window::start_tauri; -use mindwork_ai_studio::certificate::{generate_certificate}; -use mindwork_ai_studio::dotnet::start_dotnet_server; +use mindwork_ai_studio::runtime_certificate::{generate_runtime_certificate}; use mindwork_ai_studio::environment::is_dev; use mindwork_ai_studio::log::init_logging; use mindwork_ai_studio::metadata::MetaData; use mindwork_ai_studio::runtime_api::start_runtime_api; -#[cfg(debug_assertions)] -use mindwork_ai_studio::dotnet::create_startup_env_file; #[tokio::main] async fn main() { @@ -38,6 +35,7 @@ async fn main() { info!(".. MudBlazor: v{mud_blazor_version}", mud_blazor_version = metadata.mud_blazor_version); info!(".. Tauri: v{tauri_version}", tauri_version = metadata.tauri_version); info!(".. PDFium: v{pdfium_version}", pdfium_version = metadata.pdfium_version); + info!(".. Qdrant: v{qdrant_version}", qdrant_version = metadata.qdrant_version); if is_dev() { warn!("Running in development mode."); @@ -45,15 +43,8 @@ async fn main() { info!("Running in production mode."); } - generate_certificate(); + generate_runtime_certificate(); start_runtime_api(); - if is_dev() { - #[cfg(debug_assertions)] - create_startup_env_file(); - } else { - start_dotnet_server(); - } - start_tauri(); } \ No newline at end of file diff --git a/runtime/src/metadata.rs b/runtime/src/metadata.rs index 426e2b66..fa56dd68 100644 --- a/runtime/src/metadata.rs +++ b/runtime/src/metadata.rs @@ -16,6 +16,7 @@ pub struct MetaData { pub app_commit_hash: String, pub architecture: String, pub pdfium_version: String, + pub qdrant_version: String, } impl MetaData { @@ -39,6 +40,7 @@ impl MetaData { let app_commit_hash = metadata_lines.next().unwrap(); let architecture = metadata_lines.next().unwrap(); let pdfium_version = metadata_lines.next().unwrap(); + let qdrant_version = metadata_lines.next().unwrap(); let metadata = MetaData { architecture: architecture.to_string(), @@ -52,6 +54,7 @@ impl MetaData { rust_version: rust_version.to_string(), tauri_version: tauri_version.to_string(), pdfium_version: pdfium_version.to_string(), + qdrant_version: qdrant_version.to_string(), }; *META_DATA.lock().unwrap() = Some(metadata.clone()); diff --git a/runtime/src/qdrant.rs b/runtime/src/qdrant.rs new file mode 100644 index 00000000..41429431 --- /dev/null +++ b/runtime/src/qdrant.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; +use std::{fs}; +use std::error::Error; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; +use log::{debug, error, info, warn}; +use once_cell::sync::Lazy; +use rocket::get; +use rocket::serde::json::Json; +use rocket::serde::Serialize; +use tauri::api::process::{Command, CommandChild, CommandEvent}; +use crate::api_token::{APIToken}; +use crate::environment::DATA_DIRECTORY; +use crate::certificate_factory::generate_certificate; +use std::path::PathBuf; +use tempfile::{TempDir, Builder}; +use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process}; +use crate::sidecar_types::SidecarType; + +// Qdrant server process started in a separate process and can communicate +// via HTTP or gRPC with the .NET server and the runtime process +static QDRANT_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); + +// Qdrant server port (default is 6333 for HTTP and 6334 for gRPC) +static QDRANT_SERVER_PORT_HTTP: Lazy = Lazy::new(|| { + crate::network::get_available_port().unwrap_or(6333) +}); + +static QDRANT_SERVER_PORT_GRPC: Lazy = Lazy::new(|| { + crate::network::get_available_port().unwrap_or(6334) +}); + +pub static CERTIFICATE_FINGERPRINT: OnceLock = OnceLock::new(); +static API_TOKEN: Lazy = Lazy::new(|| { + crate::api_token::generate_api_token() +}); + +static TMPDIR: Lazy>> = Lazy::new(|| Mutex::new(None)); + +const PID_FILE_NAME: &str = "qdrant.pid"; +const SIDECAR_TYPE:SidecarType = SidecarType::Qdrant; + +#[derive(Serialize)] +pub struct ProvideQdrantInfo { + path: String, + port_http: u16, + port_grpc: u16, + fingerprint: String, + api_token: String, +} + +#[get("/system/qdrant/info")] +pub fn qdrant_port(_token: APIToken) -> Json { + Json(ProvideQdrantInfo { + path: Path::new(DATA_DIRECTORY.get().unwrap()).join("databases").join("qdrant").to_str().unwrap().to_string(), + port_http: *QDRANT_SERVER_PORT_HTTP, + port_grpc: *QDRANT_SERVER_PORT_GRPC, + fingerprint: CERTIFICATE_FINGERPRINT.get().expect("Certificate fingerprint not available").to_string(), + api_token: API_TOKEN.to_hex_text().to_string(), + }) +} + +/// Starts the Qdrant server in a separate process. +pub fn start_qdrant_server(){ + + let base_path = DATA_DIRECTORY.get().unwrap(); + let path = Path::new(base_path).join("databases").join("qdrant"); + if !path.exists() { + if let Err(e) = fs::create_dir_all(&path){ + error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e.to_string()); + }; + } + let (cert_path, key_path) =create_temp_tls_files(&path).unwrap(); + + let storage_path = path.join("storage").to_str().unwrap().to_string(); + let snapshot_path = path.join("snapshots").to_str().unwrap().to_string(); + let init_path = path.join(".qdrant-initalized").to_str().unwrap().to_string(); + + let qdrant_server_environment = HashMap::from_iter([ + (String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()), + (String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()), + (String::from("QDRANT_INIT_FILE_PATH"), init_path), + (String::from("QDRANT__STORAGE__STORAGE_PATH"), storage_path), + (String::from("QDRANT__STORAGE__SNAPSHOTS_PATH"), snapshot_path), + (String::from("QDRANT__TLS__CERT"), cert_path.to_str().unwrap().to_string()), + (String::from("QDRANT__TLS__KEY"), key_path.to_str().unwrap().to_string()), + (String::from("QDRANT__SERVICE__ENABLE_TLS"), "true".to_string()), + (String::from("QDRANT__SERVICE__API_KEY"), API_TOKEN.to_hex_text().to_string()), + ]); + + let server_spawn_clone = QDRANT_SERVER.clone(); + tauri::async_runtime::spawn(async move { + let (mut rx, child) = Command::new_sidecar("qdrant") + .expect("Failed to create sidecar for Qdrant") + .args(["--config-path", "resources/databases/qdrant/config.yaml"]) + .envs(qdrant_server_environment) + .spawn() + .expect("Failed to spawn Qdrant server process."); + + let server_pid = child.pid(); + info!(Source = "Bootloader Qdrant"; "Qdrant server process started with PID={server_pid}."); + log_potential_stale_process(path.join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); + + // Save the server process to stop it later: + *server_spawn_clone.lock().unwrap() = Some(child); + + // Log the output of the Qdrant server: + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => { + let line = line.trim_end(); + if line.contains("INFO") || line.contains("info") { + info!(Source = "Qdrant Server"; "{line}"); + } else if line.contains("WARN") || line.contains("warning") { + warn!(Source = "Qdrant Server"; "{line}"); + } else if line.contains("ERROR") || line.contains("error") { + error!(Source = "Qdrant Server"; "{line}"); + } else { + debug!(Source = "Qdrant Server"; "{line}"); + } + }, + + CommandEvent::Stderr(line) => { + error!(Source = "Qdrant Server (stderr)"; "{line}"); + }, + + _ => {} + } + } + }); +} + +/// Stops the Qdrant server process. +pub fn stop_qdrant_server() { + if let Some(server_process) = QDRANT_SERVER.lock().unwrap().take() { + let server_kill_result = server_process.kill(); + match server_kill_result { + Ok(_) => warn!(Source = "Qdrant"; "Qdrant server process was stopped."), + Err(e) => error!(Source = "Qdrant"; "Failed to stop Qdrant server process: {e}."), + } + } else { + warn!(Source = "Qdrant"; "Qdrant server process was not started or is already stopped."); + } + + drop_tmpdir(); + cleanup_qdrant(); +} + +/// Create temporary directory with TLS relevant files +pub fn create_temp_tls_files(path: &PathBuf) -> Result<(PathBuf, PathBuf), Box> { + let cert = generate_certificate(); + + let temp_dir = init_tmpdir_in(path); + let cert_path = temp_dir.join("cert.pem"); + let key_path = temp_dir.join("key.pem"); + + let mut cert_file = File::create(&cert_path)?; + cert_file.write_all(&*cert.certificate)?; + + let mut key_file = File::create(&key_path)?; + key_file.write_all(&*cert.private_key)?; + + CERTIFICATE_FINGERPRINT.set(cert.fingerprint).expect("Could not set the certificate fingerprint."); + + Ok((cert_path, key_path)) +} + +pub fn init_tmpdir_in>(path: P) -> PathBuf { + let mut guard = TMPDIR.lock().unwrap(); + let dir = guard.get_or_insert_with(|| { + Builder::new() + .prefix("cert-") + .tempdir_in(path) + .expect("failed to create tempdir") + }); + + dir.path().to_path_buf() +} + +pub fn drop_tmpdir() { + let mut guard = TMPDIR.lock().unwrap(); + *guard = None; + warn!(Source = "Qdrant"; "Temporary directory for TLS was dropped."); +} + +/// Remove old Pid files and kill the corresponding processes +pub fn cleanup_qdrant() { + let pid_path = Path::new(DATA_DIRECTORY.get().unwrap()).join("databases").join("qdrant").join(PID_FILE_NAME); + if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) { + warn!(Source = "Qdrant"; "Error during the cleanup of Qdrant: {}", e); + } + if let Err(e) = delete_old_certificates() { + warn!(Source = "Qdrant"; "Error during the cleanup of Qdrant: {}", e); + } + +} + +pub fn delete_old_certificates() -> Result<(), Box> { + let dir_path = Path::new(DATA_DIRECTORY.get().unwrap()).join("databases").join("qdrant"); + + if !dir_path.exists() { + return Ok(()); + } + + for entry in fs::read_dir(dir_path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + let file_name = entry.file_name(); + let folder_name = file_name.to_string_lossy(); + + if folder_name.starts_with("cert-") { + fs::remove_dir_all(&path)?; + warn!(Source="Qdrant"; "Removed old certificates in: {}", path.display()); + } + } + } + Ok(()) +} \ No newline at end of file diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index de81cc18..77f4f032 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -3,7 +3,7 @@ use once_cell::sync::Lazy; use rocket::config::Shutdown; use rocket::figment::Figment; use rocket::routes; -use crate::certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY}; +use crate::runtime_certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY}; use crate::environment::is_dev; use crate::network::get_available_port; @@ -67,6 +67,7 @@ pub fn start_runtime_api() { .mount("/", routes![ crate::dotnet::dotnet_port, crate::dotnet::dotnet_ready, + crate::qdrant::qdrant_port, crate::clipboard::set_clipboard, crate::app_window::get_event_stream, crate::app_window::check_for_update, diff --git a/runtime/src/runtime_api_token.rs b/runtime/src/runtime_api_token.rs new file mode 100644 index 00000000..f1e762c9 --- /dev/null +++ b/runtime/src/runtime_api_token.rs @@ -0,0 +1,40 @@ +use once_cell::sync::Lazy; +use rocket::http::Status; +use rocket::Request; +use rocket::request::FromRequest; +use crate::api_token::{generate_api_token, APIToken}; + +pub static API_TOKEN: Lazy = Lazy::new(|| generate_api_token()); + +/// The request outcome type used to handle API token requests. +type RequestOutcome = rocket::request::Outcome; + +/// The request outcome implementation for the API token. +#[rocket::async_trait] +impl<'r> FromRequest<'r> for APIToken { + type Error = APITokenError; + + /// Handles the API token requests. + async fn from_request(request: &'r Request<'_>) -> RequestOutcome { + let token = request.headers().get_one("token"); + match token { + Some(token) => { + let received_token = APIToken::from_hex_text(token); + if API_TOKEN.validate(&received_token) { + RequestOutcome::Success(received_token) + } else { + RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) + } + } + + None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), + } + } +} + +/// The API token error types. +#[derive(Debug)] +pub enum APITokenError { + Missing, + Invalid, +} \ No newline at end of file diff --git a/runtime/src/runtime_certificate.rs b/runtime/src/runtime_certificate.rs new file mode 100644 index 00000000..e4255861 --- /dev/null +++ b/runtime/src/runtime_certificate.rs @@ -0,0 +1,26 @@ +use std::sync::OnceLock; +use log::info; +use crate::certificate_factory::generate_certificate; + +/// The certificate used for the runtime API server. +pub static CERTIFICATE: OnceLock> = OnceLock::new(); + +/// The private key used for the certificate of the runtime API server. +pub static CERTIFICATE_PRIVATE_KEY: OnceLock> = OnceLock::new(); + +/// The fingerprint of the certificate used for the runtime API server. +pub static CERTIFICATE_FINGERPRINT: OnceLock = OnceLock::new(); + +/// Generates a TLS certificate for the runtime API server. +pub fn generate_runtime_certificate() { + + info!("Try to generate a TLS certificate for the runtime API server..."); + + let cert = generate_certificate(); + + CERTIFICATE_FINGERPRINT.set(cert.fingerprint).expect("Could not set the certificate fingerprint."); + CERTIFICATE.set(cert.certificate).expect("Could not set the certificate."); + CERTIFICATE_PRIVATE_KEY.set(cert.private_key).expect("Could not set the private key."); + + info!("Done generating certificate for the runtime API server."); +} \ No newline at end of file diff --git a/runtime/src/sidecar_types.rs b/runtime/src/sidecar_types.rs new file mode 100644 index 00000000..7e5bfde0 --- /dev/null +++ b/runtime/src/sidecar_types.rs @@ -0,0 +1,15 @@ +use std::fmt; + +pub enum SidecarType { + Dotnet, + Qdrant, +} + +impl fmt::Display for SidecarType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SidecarType::Dotnet => write!(f, ".Net"), + SidecarType::Qdrant => write!(f, "Qdrant"), + } + } +} \ No newline at end of file diff --git a/runtime/src/stale_process_cleanup.rs b/runtime/src/stale_process_cleanup.rs new file mode 100644 index 00000000..7d177ac8 --- /dev/null +++ b/runtime/src/stale_process_cleanup.rs @@ -0,0 +1,89 @@ +use std::fs; +use std::fs::File; +use std::io::{Error, ErrorKind, Write}; +use std::path::{PathBuf}; +use log::{info, warn}; +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use crate::sidecar_types::SidecarType; + +fn parse_pid_file(content: &str) -> Result<(u32, String), Error> { + let mut lines = content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()); + let pid_str = lines + .next() + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "Missing PID in file"))?; + let pid: u32 = pid_str + .parse() + .map_err(|_| Error::new(ErrorKind::InvalidData, "Invalid PID in file"))?; + let name = lines + .next() + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "Missing process name in file"))? + .to_string(); + Ok((pid, name)) +} + +pub fn kill_stale_process(pid_file_path: PathBuf, sidecar_type: SidecarType) -> Result<(), Error> { + if !pid_file_path.exists() { + return Ok(()); + } + + let pid_file_content = fs::read_to_string(&pid_file_path)?; + let (pid, expected_name) = parse_pid_file(&pid_file_content)?; + + let mut system = System::new_all(); + + let pid = Pid::from_u32(pid); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + if let Some(process) = system.process(pid){ + let name = process.name().to_string_lossy(); + if name != expected_name { + return Err(Error::new( + ErrorKind::InvalidInput, + format!( + "Process name does not match: expected '{}' but found '{}'", + expected_name, name + ), + )); + } + + let killed = process.kill_with(Signal::Kill).unwrap_or_else(|| process.kill()); + if !killed { + return Err(Error::new(ErrorKind::Other, "Failed to kill process")); + } + info!(Source="Stale Process Cleanup";"{}: Killed process: \"{}\"", sidecar_type,pid_file_path.display()); + } else { + info!(Source="Stale Process Cleanup";"{}: Pid file with process number '{}' was found, but process was not.", sidecar_type, pid); + }; + + fs::remove_file(&pid_file_path)?; + info!(Source="Stale Process Cleanup";"{}: Deleted redundant Pid file: \"{}\"", sidecar_type,pid_file_path.display()); + Ok(()) +} + +pub fn log_potential_stale_process(pid_file_path: PathBuf, pid: u32, sidecar_type: SidecarType) { + let mut system = System::new_all(); + let pid = Pid::from_u32(pid); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + let Some(process) = system.process(pid) else { + warn!(Source="Stale Process Cleanup"; + "{}: Pid file with process number '{}' was not created because the process was not found.", + sidecar_type, pid + ); + return; + }; + + match File::create(&pid_file_path) { + Ok(mut file) => { + let name = process.name().to_string_lossy(); + let content = format!("{pid}\n{name}\n"); + if let Err(e) = file.write_all(content.as_bytes()) { + warn!(Source="Stale Process Cleanup";"{}: Failed to write to \"{}\": {}", sidecar_type,pid_file_path.display(), e); + } + } + Err(e) => { + warn!(Source="Stale Process Cleanup";"{}: Failed to create \"{}\": {}", sidecar_type, pid_file_path.display(), e); + } + } +} diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 216b17e3..90471450 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -20,6 +20,11 @@ "name": "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", "sidecar": true, "args": true + }, + { + "name": "target/databases/qdrant/qdrant", + "sidecar": true, + "args": true } ] }, @@ -59,7 +64,8 @@ "targets": "all", "identifier": "com.github.mindwork-ai.ai-studio", "externalBin": [ - "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer" + "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", + "target/databases/qdrant/qdrant" ], "resources": [ "resources/*" From 4cf62672de7dece601b6d90b235b30ead823c7df Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 7 Feb 2026 22:59:41 +0100 Subject: [PATCH 09/34] Added settings & features for administrators in organizations (#653) --- .../DocumentAnalysisAssistant.razor | 15 +- .../Assistants/I18N/allTexts.lua | 48 ++++ .../Settings/SettingsPanelApp.razor | 24 +- .../Settings/SettingsPanelApp.razor.cs | 6 + .../Components/Settings/SettingsPanelBase.cs | 3 + .../Settings/SettingsPanelEmbeddings.razor | 9 +- .../Settings/SettingsPanelEmbeddings.razor.cs | 12 +- .../Settings/SettingsPanelProviderBase.cs | 61 +++++ .../Settings/SettingsPanelProviders.razor | 9 +- .../Settings/SettingsPanelProviders.razor.cs | 12 +- .../Settings/SettingsPanelTranscription.razor | 7 +- .../SettingsPanelTranscription.razor.cs | 12 +- .../Layout/MainLayout.razor.cs | 3 + .../Pages/Information.razor | 50 ++++- .../Plugins/configuration/plugin.lua | 28 ++- .../plugin.lua | 50 ++++- .../plugin.lua | 50 ++++- .../Provider/BaseProvider.cs | 7 +- .../Provider/LLMProvidersExtensions.cs | 44 ++-- .../Settings/DataModel/DataApp.cs | 7 +- .../Settings/EmbeddingProvider.cs | 64 +++++- app/MindWork AI Studio/Settings/Provider.cs | 87 +++++++- .../Settings/TranscriptionProvider.cs | 64 +++++- .../Tools/EnterpriseEncryption.cs | 211 ++++++++++++++++++ app/MindWork AI Studio/Tools/ISecretId.cs | 7 + app/MindWork AI Studio/Tools/LuaTools.cs | 16 ++ .../PluginSystem/PendingEnterpriseApiKey.cs | 49 ++++ .../Tools/PluginSystem/PluginConfiguration.cs | 48 +++- .../PluginSystem/PluginConfigurationObject.cs | 32 ++- .../PluginSystem/PluginFactory.Loading.cs | 20 +- .../Tools/PluginSystem/PluginFactory.cs | 23 ++ .../Tools/Services/RustService.Enterprise.cs | 20 ++ app/MindWork AI Studio/packages.lock.json | 37 +++ .../wwwroot/changelog/v26.2.2.md | 6 +- documentation/Enterprise IT.md | 63 +++++- runtime/Cargo.lock | 191 +++++++++++++++- runtime/src/environment.rs | 24 ++ runtime/src/runtime_api.rs | 1 + 38 files changed, 1330 insertions(+), 90 deletions(-) create mode 100644 app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs create mode 100644 app/MindWork AI Studio/Tools/EnterpriseEncryption.cs create mode 100644 app/MindWork AI Studio/Tools/LuaTools.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index aa77f9fb..51dd8f7d 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -136,13 +136,16 @@ else - - @T("Preparation for enterprise distribution") - + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + @T("Preparation for enterprise distribution") + - - @T("Export policy as configuration section") - + + @T("Export policy as configuration section") + + } } diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index f825d229..e1188a53 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2080,12 +2080,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -2095,6 +2101,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Administration settings are visible" + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" @@ -2104,9 +2113,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Show administration settings?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Read the Enterprise IT documentation for details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Enable spellchecking?" @@ -2140,6 +2158,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Generate an encryption secret and copy it to the clipboard" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" @@ -2197,6 +2221,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Export configuration" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Cannot export the encrypted API key: No enterprise encryption secret is configured." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "Export API Key?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Show provider's confidence level?" @@ -2302,6 +2338,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Export configuration" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "No transcription provider configured yet." @@ -2356,6 +2395,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Export configuration" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard" @@ -5017,6 +5059,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." @@ -5053,6 +5098,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "In order to use -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 62b996d0..a07fc65f 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -17,6 +17,7 @@ + @if (this.SettingsManager.ConfigurationData.App.PreviewVisibility > PreviewVisibility.NONE) @@ -36,4 +37,25 @@ } - \ No newline at end of file + + @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + { + + @T("Enterprise Administration") + + + + @T("Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings.") + + @T("Read the Enterprise IT documentation for details.") + + + + + @T("Generate an encryption secret and copy it to the clipboard") + + } + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs index 2fbb61ed..81c2b7e5 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs @@ -6,6 +6,12 @@ namespace AIStudio.Components.Settings; public partial class SettingsPanelApp : SettingsPanelBase { + private async Task GenerateEncryptionSecret() + { + var secret = EnterpriseEncryption.GenerateSecret(); + await this.RustService.CopyText2Clipboard(this.Snackbar, secret); + } + private IEnumerable> GetFilteredTranscriptionProviders() { yield return new(T("Disable dictation and transcription"), string.Empty); diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs index bad3fca3..871d8353 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelBase.cs @@ -15,4 +15,7 @@ public abstract class SettingsPanelBase : MSGComponentBase [Inject] protected RustService RustService { get; init; } = null!; + + [Inject] + protected ISnackbar Snackbar { get; init; } = null!; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index 874bc3c9..addc4088 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings.DataModel -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager)) { @@ -22,7 +22,7 @@ - + # @@ -53,6 +53,9 @@ + + + @@ -73,4 +76,4 @@ @T("Add Embedding") -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 94878987..02b46c1a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -7,7 +7,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelEmbeddings : SettingsPanelBase +public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase { [Parameter] public List> AvailableEmbeddingProviders { get; set; } = new(); @@ -114,6 +114,14 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase await this.UpdateEmbeddingProviders(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportEmbeddingProvider(EmbeddingProvider provider) + { + if (provider == EmbeddingProvider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.EMBEDDING_PROVIDER, provider.ExportAsConfigurationSection); + } private async Task UpdateEmbeddingProviders() { @@ -123,4 +131,4 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase await this.AvailableEmbeddingProvidersChanged.InvokeAsync(this.AvailableEmbeddingProviders); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs new file mode 100644 index 00000000..9503365c --- /dev/null +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviderBase.cs @@ -0,0 +1,61 @@ +using AIStudio.Dialogs; +using AIStudio.Tools.PluginSystem; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Components.Settings; + +public abstract class SettingsPanelProviderBase : SettingsPanelBase +{ + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SettingsPanelProviderBase).Namespace, nameof(SettingsPanelProviderBase)); + + /// + /// Exports the provider configuration as Lua code, optionally including the encrypted API key if the provider has one + /// configured and the user agrees to include it. The exportFunc should generate the Lua code based on the provided + /// encrypted API key (which may be null if the user chose not to include it or if encryption is not available). + /// The generated Lua code is then copied to the clipboard for easy sharing. + /// + /// The secret ID of the provider to check for an API key. + /// The type of secret store to check for the API key (e.g., LLM provider, transcription provider, etc.). + /// The function that generates the Lua code for the provider configuration, given the optional encrypted API key. + protected async Task ExportProvider(ISecretId secretId, SecretStoreType storeType, Func exportFunc) + { + string? encryptedApiKey = null; + + // Check if the provider has an API key stored: + var apiKeyResponse = await this.RustService.GetAPIKey(secretId, storeType, isTrying: true); + if (apiKeyResponse.Success) + { + // Ask the user if they want to export the API key: + var dialogParameters = new DialogParameters + { + { x => x.Message, TB("This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(TB("Export API Key?"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is { Canceled: false }) + { + // User wants to export the API key - encrypt it: + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + var decryptedApiKey = await apiKeyResponse.Secret.Decrypt(Program.ENCRYPTION); + if (encryption.TryEncrypt(decryptedApiKey, out var encrypted)) + encryptedApiKey = encrypted; + } + else + { + // No encryption secret available - inform the user: + this.Snackbar.Add(TB("Cannot export the encrypted API key: No enterprise encryption secret is configured."), Severity.Warning); + } + } + } + + var luaCode = exportFunc(encryptedApiKey); + if (string.IsNullOrWhiteSpace(luaCode)) + return; + + await this.RustService.CopyText2Clipboard(this.Snackbar, luaCode); + } +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index 3d359408..21cc511d 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @@ -15,7 +15,7 @@ - + # @@ -45,6 +45,9 @@ + + + @@ -117,4 +120,4 @@ } } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 2272959d..3388372a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -10,7 +10,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelProviders : SettingsPanelBase +public partial class SettingsPanelProviders : SettingsPanelProviderBase { [Parameter] public List> AvailableLLMProviders { get; set; } = new(); @@ -134,6 +134,14 @@ public partial class SettingsPanelProviders : SettingsPanelBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + private async Task ExportLLMProvider(AIStudio.Settings.Provider provider) + { + if (provider == AIStudio.Settings.Provider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.LLM_PROVIDER, provider.ExportAsConfigurationSection); + } + private string GetLLMProviderModelName(AIStudio.Settings.Provider provider) { // For system models, return localized text: @@ -176,4 +184,4 @@ public partial class SettingsPanelProviders : SettingsPanelBase this.SettingsManager.ConfigurationData.LLMProviders.CustomConfidenceScheme[llmProvider] = level; await this.SettingsManager.StoreSettings(); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index aff415b2..43da4dc6 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -1,6 +1,6 @@ @using AIStudio.Provider @using AIStudio.Settings.DataModel -@inherits SettingsPanelBase +@inherits SettingsPanelProviderBase @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { @@ -19,7 +19,7 @@ - + # @@ -50,6 +50,9 @@ + + + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs index 243200a3..fadd002a 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs @@ -7,7 +7,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions; namespace AIStudio.Components.Settings; -public partial class SettingsPanelTranscription : SettingsPanelBase +public partial class SettingsPanelTranscription : SettingsPanelProviderBase { [Parameter] public List> AvailableTranscriptionProviders { get; set; } = new(); @@ -114,6 +114,14 @@ public partial class SettingsPanelTranscription : SettingsPanelBase await this.UpdateTranscriptionProviders(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private async Task ExportTranscriptionProvider(TranscriptionProvider provider) + { + if (provider == TranscriptionProvider.NONE) + return; + + await this.ExportProvider(provider, SecretStoreType.TRANSCRIPTION_PROVIDER, provider.ExportAsConfigurationSection); + } private async Task UpdateTranscriptionProviders() { @@ -123,4 +131,4 @@ public partial class SettingsPanelTranscription : SettingsPanelBase await this.AvailableTranscriptionProvidersChanged.InvokeAsync(this.AvailableTranscriptionProviders); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 4516b81b..af5f3a5b 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -215,6 +215,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan if (enterpriseEnvironment != default) await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl); + // Initialize the enterprise encryption service for decrypting API keys: + await PluginFactory.InitializeEnterpriseEncryption(this.RustService); + // Load (but not start) all plugins without waiting for them: #if DEBUG var pluginLoadingTimeout = new CancellationTokenSource(); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 72673982..32238d06 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -1,4 +1,5 @@ @attribute [Route(Routes.ABOUT)] +@using AIStudio.Tools.PluginSystem @using AIStudio.Tools.Services @inherits MSGComponentBase @@ -68,9 +69,24 @@ + +
+ + @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) + { + + @T("Encryption secret: is configured") + } + else + { + + @T("Encryption secret: is not configured") + } +
+
break; - + case true when this.configPlug is null: @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.") @@ -91,9 +107,24 @@ + +
+ + @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) + { + + @T("Encryption secret: is configured") + } + else + { + + @T("Encryption secret: is not configured") + } +
+
break; - + case true: @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.") @@ -122,6 +153,21 @@ + +
+ + @if (PluginFactory.EnterpriseEncryption?.IsAvailable is true) + { + + @T("Encryption secret: is configured") + } + else + { + + @T("Encryption secret: is not configured") + } +
+
break; } diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 73061b73..3e12a567 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -64,6 +64,20 @@ CONFIG["LLM_PROVIDERS"] = {} -- -- Could be something like ... \"temperature\": 0.5, \"max_tokens\": 1000 ... for multiple parameters. -- -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. -- ["AdditionalJsonApiParameters"] = "", +-- +-- -- Optional: Hugging Face inference provider. Only relevant for UsedLLMProvider = HUGGINGFACE. +-- -- Allowed values are: CEREBRAS, NEBIUS_AI_STUDIO, SAMBANOVA, NOVITA, HYPERBOLIC, TOGETHER_AI, FIREWORKS, HF_INFERENCE_API +-- -- ["HFInferenceProvider"] = "NOVITA", +-- +-- -- Optional: Encrypted API key for cloud providers or secured on-premise models. +-- -- The API key must be encrypted using the enterprise encryption secret. +-- -- Format: "ENC:v1:" +-- -- The encryption secret must be configured via: +-- -- Windows Registry: HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret +-- -- Environment variable: MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET +-- -- You can export an encrypted API key from an existing provider using the export button in the settings. +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -82,6 +96,10 @@ CONFIG["TRANSCRIPTION_PROVIDERS"] = {} -- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, VLLM, and WHISPER_CPP -- ["Host"] = "WHISPER_CPP", -- ["Hostname"] = "", +-- +-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details) +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -100,6 +118,10 @@ CONFIG["EMBEDDING_PROVIDERS"] = {} -- -- Allowed values for Host are: LM_STUDIO, LLAMACPP, OLLAMA, and VLLM -- ["Host"] = "OLLAMA", -- ["Hostname"] = "", +-- +-- -- Optional: Encrypted API key (see LLM_PROVIDERS example for details) +-- -- ["APIKey"] = "ENC:v1:", +-- -- ["Model"] = { -- ["Id"] = "", -- ["DisplayName"] = "", @@ -120,6 +142,10 @@ CONFIG["SETTINGS"] = {} -- Allowed values are: true, false -- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false +-- Configure whether administration settings are visible in the UI: +-- Allowed values are: true, false +-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true + -- Configure the visibility of preview features: -- Allowed values are: NONE, RELEASE_CANDIDATE, BETA, ALPHA, PROTOTYPE, EXPERIMENTAL -- Please note: @@ -260,4 +286,4 @@ CONFIG["PROFILES"] = {} -- ["Name"] = "", -- ["NeedToKnow"] = "I like to cook in my free time. My favorite meal is ...", -- ["Actions"] = "Please always ensure the portion size is ..." --- } \ No newline at end of file +-- } diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 5f4adf5d..9612b2ef 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -2082,12 +2082,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Wählen Sie die Sprache für die App aus." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "Wenn diese Option aktiviert ist, werden zusätzliche Optionen für die Administration angezeigt. Diese Optionen sind für IT-Mitarbeitende vorgesehen, um organisationsweite Einstellungen zu verwalten, z. B. Anbieter für eine gesamte Organisation zu konfigurieren und zu exportieren." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "Der globale Tastaturkurzbefehl zum Ein- und Ausschalten der Sprachaufnahme. Dieser Kurzbefehl funktioniert systemweit, auch wenn die App nicht im Vordergrund ist." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Diktieren und Transkribieren deaktivieren" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Unternehmensverwaltung" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Sprachverhalten" @@ -2097,6 +2103,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Sprache" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Die Optionen für die Administration sind sichtbar." + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Energie sparen?" @@ -2106,9 +2115,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App-Einstellungen" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generieren Sie ein 256‑Bit‑Geheimnis für die Verschlüsselung, um API‑Schlüssel in Konfigurations-Plugins zu verschlüsseln. Stellen Sie dieses Geheimnis über Gruppenrichtlinien (Windows-Registrierung) oder über Umgebungsvariablen auf Client-Geräten bereit. Anschließend können Anbieter über die Export-Schaltflächen in den Anbieter-Einstellungen mit verschlüsselten API‑Schlüsseln exportiert werden." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "Wenn aktiviert, wird gestreamter Inhalt von der KI alle drei Sekunden aktualisiert. Wenn deaktiviert, wird gestreamter Inhalt sofort aktualisiert, sobald er verfügbar ist." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Optionen für die Administration anzeigen?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Lesen Sie die Enterprise-IT-Dokumentation für die Details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Rechtschreibprüfung aktivieren?" @@ -2142,6 +2160,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Möchten Sie einen Anbieter als Standard für die gesamte App festlegen? Wenn Sie einen anderen Anbieter für einen Assistenten konfigurieren, hat dieser immer Vorrang." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Geheimnis für die Verschlüsselung generieren und in die Zwischenablage kopieren" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Die Optionen für die Administration sind nicht sichtbar." + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Löschen" @@ -2199,6 +2223,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Konfiguration exportieren" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Der verschlüsselte API-Schlüssel kann nicht exportiert werden: Es ist kein Geheimnis für die Verschlüsselung konfiguriert." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "Für diesen Anbieter ist ein API-Schlüssel konfiguriert. Möchten Sie den verschlüsselten API-Schlüssel in den Export aufnehmen? Hinweis: Der Empfänger benötigt dasselbe Geheimnis für die Verschlüsselung, um den API-Schlüssel verwenden zu können." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "API-Schlüssel exportieren?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Anzeigen, wie sicher sich der Anbieter ist?" @@ -2304,6 +2340,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Konfiguration exportieren" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "Es ist bisher kein Anbieter für Transkriptionen konfiguriert." @@ -2358,6 +2397,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Anbieter" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Konfiguration exportieren" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Kopiere {0} in die Zwischenablage" @@ -5019,7 +5061,10 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Datenbankversion -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "Diese Bibliothek wird verwendet, um die MudBlazor-Bibliothek zu erweitern. Sie stellt zusätzliche Komponenten bereit, die nicht Teil der MudBlazor-Bibliothek sind." --- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert" + +-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant ist eine Vektordatenbank und Suchmaschine für Vektoren. Wir nutzen Qdrant, um lokales RAG (Retrieval-Augmented Generation) innerhalb von AI Studio zu realisieren. Vielen Dank für den Einsatz und die großartige Arbeit, die in Qdrant gesteckt wurde und weiterhin gesteckt wird." -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. @@ -5055,6 +5100,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "Um ein beliebige -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek wird verwendet, um HTML in Markdown umzuwandeln. Das ist zum Beispiel notwendig, wenn Sie eine URL als Eingabe für einen Assistenten angeben." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Geheimnis für die Verschlüsselung: ist konfiguriert" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "Wir verwenden Rocket zur Implementierung der Runtime-API. Dies ist notwendig, da die Runtime mit der Benutzeroberfläche (IPC) kommunizieren muss. Rocket ist ein ausgezeichnetes Framework zur Umsetzung von Web-APIs in Rust." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 5a4f9f78..4be2328f 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -2082,12 +2082,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"] -- Select the language for the app. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." +-- When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2013281167"] = "When enabled, additional administration options become visible. These options are intended for IT staff to manage organization-wide configuration, e.g. configuring and exporting providers for an entire organization." + -- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused." -- Disable dictation and transcription UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" +-- Enterprise Administration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -2097,6 +2103,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T237706157"] -- Language UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591284123"] = "Language" +-- Administration settings are visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2591866808"] = "Administration settings are visible" + -- Save energy? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" @@ -2106,9 +2115,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" +-- Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T362833"] = "Generate a 256-bit encryption secret for encrypting API keys in configuration plugins. Deploy this secret to client machines via Group Policy (Windows Registry) or environment variables. Providers can then be exported with encrypted API keys using the export buttons in the provider settings." + -- When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3652888444"] = "When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available." +-- Show administration settings? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3694781396"] = "Show administration settings?" + +-- Read the Enterprise IT documentation for details. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3705451321"] = "Read the Enterprise IT documentation for details." + -- Enable spellchecking? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"] = "Enable spellchecking?" @@ -2142,6 +2160,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T817101267"] -- Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T844514734"] = "Would you like to set one provider as the default for the entire app? When you configure a different provider for an assistant, it will always take precedence." +-- Generate an encryption secret and copy it to the clipboard +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T922066419"] = "Generate an encryption secret and copy it to the clipboard" + +-- Administration settings are not visible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T929143445"] = "Administration settings are not visible" + -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1469573738"] = "Delete" @@ -2199,6 +2223,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T78223 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T975426229"] = "Export configuration" + +-- Cannot export the encrypted API key: No enterprise encryption secret is configured. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T1832230847"] = "Cannot export the encrypted API key: No enterprise encryption secret is configured." + +-- This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T3368145670"] = "This provider has an API key configured. Do you want to include the encrypted API key in the export? Note: The recipient will need the same encryption secret to use the API key." + +-- Export API Key? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERBASE::T4010580285"] = "Export API Key?" + -- Show provider's confidence level? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1052533048"] = "Show provider's confidence level?" @@ -2304,6 +2340,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T853225 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T975426229"] = "Export configuration" + -- No transcription provider configured yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1079350363"] = "No transcription provider configured yet." @@ -2358,6 +2397,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Provider" +-- Export configuration +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T975426229"] = "Export configuration" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard" @@ -5019,7 +5061,10 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." --- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +-- Encryption secret: is not configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" + +-- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. @@ -5055,6 +5100,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1915240766"] = "In order to use -- This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant." +-- Encryption secret: is configured +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured" + -- We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1943216839"] = "We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust." diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 8400e9a3..0cf8a362 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -112,9 +112,14 @@ public abstract class BaseProvider : IProvider, ISecretId #endregion + /// + /// Whether this provider was imported from an enterprise configuration plugin. + /// + public bool IsEnterpriseConfiguration { get; init; } + #region Implementation of ISecretId - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; public string SecretName => this.InstanceName; diff --git a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs index ffaa0d06..e71cef95 100644 --- a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs +++ b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs @@ -186,7 +186,7 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this AIStudio.Settings.Provider providerSettings) { - return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.Model, providerSettings.HFInferenceProvider, providerSettings.AdditionalJsonApiParameters); + return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.Model, providerSettings.HFInferenceProvider, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration); } /// @@ -196,7 +196,7 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this EmbeddingProvider embeddingProviderSettings) { - return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.Model, HFInferenceProvider.NONE); + return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.Model, HFInferenceProvider.NONE, isEnterpriseConfiguration: embeddingProviderSettings.IsEnterpriseConfiguration); } /// @@ -206,34 +206,34 @@ public static class LLMProvidersExtensions /// The provider instance. public static IProvider CreateProvider(this TranscriptionProvider transcriptionProviderSettings) { - return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.Model, HFInferenceProvider.NONE); + return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.Model, HFInferenceProvider.NONE, isEnterpriseConfiguration: transcriptionProviderSettings.IsEnterpriseConfiguration); } - private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, Model model, HFInferenceProvider inferenceProvider, string expertProviderApiParameter = "") + private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, Model model, HFInferenceProvider inferenceProvider, string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false) { try { return provider switch { - LLMProviders.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.MISTRAL => new ProviderMistral { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.GOOGLE => new ProviderGoogle { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.X => new ProviderX { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.DEEP_SEEK => new ProviderDeepSeek { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.ALIBABA_CLOUD => new ProviderAlibabaCloud { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, + LLMProviders.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.MISTRAL => new ProviderMistral { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.GOOGLE => new ProviderGoogle { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.X => new ProviderX { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.DEEP_SEEK => new ProviderDeepSeek { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.ALIBABA_CLOUD => new ProviderAlibabaCloud { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, model) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.SELF_HOSTED => new ProviderSelfHosted(host, hostname) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + + LLMProviders.HELMHOLTZ => new ProviderHelmholtz { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, + LLMProviders.GWDG => new ProviderGWDG { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration }, - LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, model) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - - LLMProviders.SELF_HOSTED => new ProviderSelfHosted(host, hostname) { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - - LLMProviders.HELMHOLTZ => new ProviderHelmholtz { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - LLMProviders.GWDG => new ProviderGWDG { InstanceName = instanceName, AdditionalJsonApiParameters = expertProviderApiParameter }, - _ => new NoProvider(), }; } diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index fe4409de..5671908f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -93,9 +93,14 @@ public sealed class DataApp(Expression>? configSelection = n /// Should the user be allowed to add providers? /// public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true); + + /// + /// Should administration settings be visible in the UI? + /// + public bool ShowAdminSettings { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShowAdminSettings, false); /// /// List of assistants that should be hidden from the UI. /// public HashSet HiddenAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HiddenAssistants, []); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs index e88831f0..59909b25 100644 --- a/app/MindWork AI Studio/Settings/EmbeddingProvider.cs +++ b/app/MindWork AI Studio/Settings/EmbeddingProvider.cs @@ -43,7 +43,7 @@ public sealed record EmbeddingProvider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -110,6 +110,34 @@ public sealed record EmbeddingProvider( Host = host, }; + // Handle encrypted API key if present: + if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) + { + if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) + LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + name, + decryptedApiKey, + SecretStoreType.EMBEDDING_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -131,4 +159,36 @@ public sealed record EmbeddingProvider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the embedding provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["EMBEDDING_PROVIDERS"][#CONFIG["EMBEDDING_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{apiKeyLine}} + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 89a0dbbd..2990655a 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -71,7 +71,7 @@ public sealed record Provider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -121,6 +121,16 @@ public sealed record Provider( LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname."); return false; } + + var hfInferenceProvider = HFInferenceProvider.NONE; + if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead(out var hfInferenceProviderText)) + { + if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider)) + { + LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value."); + hfInferenceProvider = HFInferenceProvider.NONE; + } + } if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead(out var modelTable)) { @@ -153,9 +163,38 @@ public sealed record Provider( EnterpriseConfigurationPluginId = configPluginId, Hostname = hostname, Host = host, + HFInferenceProvider = hfInferenceProvider, AdditionalJsonApiParameters = additionalJsonApiParameters, }; - + + // Handle encrypted API key if present: + if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) + { + if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) + LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + instanceName, + decryptedApiKey, + SecretStoreType.LLM_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -177,4 +216,46 @@ public sealed record Provider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var hfInferenceProviderLine = string.Empty; + if (this.HFInferenceProvider is not HFInferenceProvider.NONE) + { + hfInferenceProviderLine = $""" + ["HFInferenceProvider"] = "{this.HFInferenceProvider}", + """; + } + + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["InstanceName"] = "{{LuaTools.EscapeLuaString(this.InstanceName)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{hfInferenceProviderLine}} + {{apiKeyLine}} + ["AdditionalJsonApiParameters"] = "{{LuaTools.EscapeLuaString(this.AdditionalJsonApiParameters)}}", + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs index 7a5f2ef5..c4acf865 100644 --- a/app/MindWork AI Studio/Settings/TranscriptionProvider.cs +++ b/app/MindWork AI Studio/Settings/TranscriptionProvider.cs @@ -43,7 +43,7 @@ public sealed record TranscriptionProvider( /// [JsonIgnore] - public string SecretId => this.Id; + public string SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.UsedLLMProvider.ToName()}" : this.UsedLLMProvider.ToName(); /// [JsonIgnore] @@ -110,6 +110,34 @@ public sealed record TranscriptionProvider( Host = host, }; + // Handle encrypted API key if present: + if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText)) + { + if (!EnterpriseEncryption.IsEncrypted(apiKeyText)) + LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported."); + else + { + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable == true) + { + if (encryption.TryDecrypt(apiKeyText, out var decryptedApiKey)) + { + // Queue the API key for storage in the OS keyring: + PendingEnterpriseApiKeys.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{usedLLMProvider.ToName()}", + name, + decryptedApiKey, + SecretStoreType.TRANSCRIPTION_PROVIDER)); + LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring."); + } + else + LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect."); + } + else + LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured."); + } + } + return true; } @@ -131,4 +159,36 @@ public sealed record TranscriptionProvider( model = new(id, displayName); return true; } -} \ No newline at end of file + + /// + /// Exports the transcription provider configuration as a Lua configuration section. + /// + /// Optional encrypted API key to include in the export. + /// A Lua configuration section string. + public string ExportAsConfigurationSection(string? encryptedApiKey = null) + { + var apiKeyLine = string.Empty; + if (!string.IsNullOrWhiteSpace(encryptedApiKey)) + { + apiKeyLine = $""" + ["APIKey"] = "{LuaTools.EscapeLuaString(encryptedApiKey)}", + """; + } + + return $$""" + CONFIG["TRANSCRIPTION_PROVIDERS"][#CONFIG["TRANSCRIPTION_PROVIDERS"]+1] = { + ["Id"] = "{{Guid.NewGuid().ToString()}}", + ["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}", + ["UsedLLMProvider"] = "{{this.UsedLLMProvider}}", + + ["Host"] = "{{this.Host}}", + ["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}", + {{apiKeyLine}} + ["Model"] = { + ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", + ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? string.Empty)}}", + }, + } + """; + } +} diff --git a/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs b/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs new file mode 100644 index 00000000..d32aeb1b --- /dev/null +++ b/app/MindWork AI Studio/Tools/EnterpriseEncryption.cs @@ -0,0 +1,211 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AIStudio.Tools; + +/// +/// Provides encryption and decryption functionality for enterprise configuration plugins. +/// This is used to encrypt/decrypt API keys in Lua configuration files. +/// +/// +/// Important: This is obfuscation, not security. Users with administrative access +/// to their machines can potentially extract the decrypted API keys. This feature +/// is designed to prevent casual exposure of API keys in configuration files. It +/// also protects against accidental leaks while sharing configuration snippets, +/// as the encrypted values cannot be decrypted without the secret key. +/// +public sealed class EnterpriseEncryption +{ + /// + /// The number of iterations to derive the key and IV from the password. + /// We use a higher iteration count here because the secret is static + /// (not regenerated each startup like the IPC encryption). + /// + private const int ITERATIONS = 10_000; + + /// + /// The length of the salt in bytes. + /// + private const int SALT_LENGTH = 16; + + /// + /// The prefix for encrypted values. + /// + private const string PREFIX = "ENC:v1:"; + + private readonly ILogger logger; + private readonly byte[]? secretKey; + + /// + /// Gets a value indicating whether the encryption service is available. + /// + public bool IsAvailable { get; } + + /// + /// Creates a new instance of the enterprise encryption service. + /// + /// The logger instance. + /// The base64-encoded 32-byte encryption secret. + public EnterpriseEncryption(ILogger logger, string? base64Secret) + { + this.logger = logger; + + if (string.IsNullOrWhiteSpace(base64Secret)) + { + this.logger.LogWarning("No enterprise encryption secret configured. Encrypted API keys in configuration plugins will not be available."); + this.IsAvailable = false; + return; + } + + try + { + this.secretKey = Convert.FromBase64String(base64Secret); + if (this.secretKey.Length != 32) + { + this.logger.LogWarning($"The enterprise encryption secret must be exactly 32 bytes (256 bits). Got {this.secretKey.Length} bytes."); + this.secretKey = null; + this.IsAvailable = false; + return; + } + + this.IsAvailable = true; + this.logger.LogInformation("Enterprise encryption service initialized successfully."); + } + catch (FormatException ex) + { + this.logger.LogWarning(ex, "Failed to decode the enterprise encryption secret from base64."); + this.IsAvailable = false; + } + } + + /// + /// Checks if the given value is encrypted (has the encryption prefix). + /// + /// The value to check. + /// True if the value starts with the encryption prefix; otherwise, false. + public static bool IsEncrypted(string? value) => value?.StartsWith(PREFIX, StringComparison.Ordinal) ?? false; + + /// + /// Tries to decrypt an encrypted value. + /// + /// The encrypted value (with ENC:v1: prefix). + /// When successful, contains the decrypted plaintext. + /// True if decryption was successful; otherwise, false. + public bool TryDecrypt(string encryptedValue, out string decryptedValue) + { + decryptedValue = string.Empty; + if (!this.IsAvailable) + { + this.logger.LogWarning("Cannot decrypt: Enterprise encryption service is not available."); + return false; + } + + if (!IsEncrypted(encryptedValue)) + { + this.logger.LogWarning("Cannot decrypt: Value does not have the expected encryption prefix."); + return false; + } + + try + { + // Extract the base64-encoded data after the prefix: + var base64Data = encryptedValue[PREFIX.Length..]; + var encryptedBytes = Convert.FromBase64String(base64Data); + if (encryptedBytes.Length < SALT_LENGTH + 1) + { + this.logger.LogWarning("Cannot decrypt: Encrypted data is too short."); + return false; + } + + // Extract salt and encrypted content: + var salt = encryptedBytes[..SALT_LENGTH]; + var cipherText = encryptedBytes[SALT_LENGTH..]; + + // Derive key and IV using PBKDF2: + using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512); + var key = keyDerivation.GetBytes(32); // AES-256 + var iv = keyDerivation.GetBytes(16); // AES block size + + // Decrypt using AES-256-CBC: + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var decryptedBytes = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length); + decryptedValue = Encoding.UTF8.GetString(decryptedBytes); + + return true; + } + catch (FormatException ex) + { + this.logger.LogWarning(ex, "Failed to decode encrypted value from base64."); + return false; + } + catch (CryptographicException ex) + { + this.logger.LogWarning(ex, "Failed to decrypt value. The encryption secret may be incorrect."); + return false; + } + } + + /// + /// Encrypts a plaintext value. + /// + /// The plaintext to encrypt. + /// When successful, contains the encrypted value with prefix. + /// True if encryption was successful; otherwise, false. + public bool TryEncrypt(string plaintext, out string encryptedValue) + { + encryptedValue = string.Empty; + if (!this.IsAvailable) + { + this.logger.LogWarning("Cannot encrypt: Enterprise encryption service is not available."); + return false; + } + + try + { + // Generate a random salt: + var salt = RandomNumberGenerator.GetBytes(SALT_LENGTH); + + // Derive key and IV using PBKDF2: + using var keyDerivation = new Rfc2898DeriveBytes(this.secretKey!, salt, ITERATIONS, HashAlgorithmName.SHA512); + var key = keyDerivation.GetBytes(32); // AES-256 + var iv = keyDerivation.GetBytes(16); // AES block size + + // Encrypt using AES-256-CBC: + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var cipherText = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); + + // Combine salt and ciphertext + var combined = new byte[SALT_LENGTH + cipherText.Length]; + Array.Copy(salt, 0, combined, 0, SALT_LENGTH); + Array.Copy(cipherText, 0, combined, SALT_LENGTH, cipherText.Length); + + // Encode to base64 and add the prefix: + encryptedValue = PREFIX + Convert.ToBase64String(combined); + return true; + } + catch (CryptographicException ex) + { + this.logger.LogWarning(ex, "Failed to encrypt value."); + return false; + } + } + + /// + /// Generates a new random 32-byte secret key and returns it as a base64 string. + /// + /// A base64-encoded 32-byte secret key. + public static string GenerateSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); +} diff --git a/app/MindWork AI Studio/Tools/ISecretId.cs b/app/MindWork AI Studio/Tools/ISecretId.cs index c1198913..42ee817f 100644 --- a/app/MindWork AI Studio/Tools/ISecretId.cs +++ b/app/MindWork AI Studio/Tools/ISecretId.cs @@ -5,6 +5,13 @@ namespace AIStudio.Tools; /// public interface ISecretId { + /// + /// Prefix used for secrets imported from enterprise configuration plugins. + /// This helps distinguish enterprise-managed keys from user-added keys + /// in the OS keyring. + /// + public const string ENTERPRISE_KEY_PREFIX = "config-plugin"; + /// /// The unique ID of the secret. /// diff --git a/app/MindWork AI Studio/Tools/LuaTools.cs b/app/MindWork AI Studio/Tools/LuaTools.cs new file mode 100644 index 00000000..0df50cd0 --- /dev/null +++ b/app/MindWork AI Studio/Tools/LuaTools.cs @@ -0,0 +1,16 @@ +namespace AIStudio.Tools; + +public static class LuaTools +{ + public static string EscapeLuaString(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs new file mode 100644 index 00000000..5f1cb58b --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs @@ -0,0 +1,49 @@ +namespace AIStudio.Tools.PluginSystem; + +/// +/// Represents a pending API key that needs to be stored in the OS keyring. +/// This is used during plugin loading to collect API keys from configuration plugins +/// before storing them asynchronously. +/// +/// The secret ID (provider ID). +/// The secret name (provider instance name). +/// The decrypted API key. +/// The type of secret store to use. +public sealed record PendingEnterpriseApiKey( + string SecretId, + string SecretName, + string ApiKey, + SecretStoreType StoreType); + +/// +/// Static container for pending API keys during plugin loading. +/// +public static class PendingEnterpriseApiKeys +{ + private static readonly List PENDING_KEYS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending API key to the list. + /// + /// The pending API key to add. + public static void Add(PendingEnterpriseApiKey key) + { + lock (LOCK) + PENDING_KEYS.Add(key); + } + + /// + /// Gets and clears all pending API keys. + /// + /// A list of all pending API keys. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var keys = PENDING_KEYS.ToList(); + PENDING_KEYS.Clear(); + return keys; + } + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 29d95e76..a8e10d5d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -1,4 +1,5 @@ using AIStudio.Settings; +using AIStudio.Tools.Services; using Lua; @@ -8,7 +9,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration)); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); - + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration)); + private List configObjects = []; /// @@ -23,11 +25,50 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT if (!dryRun) { + // Store any decrypted API keys from enterprise configuration in the OS keyring: + await StoreEnterpriseApiKeysAsync(); + await SETTINGS_MANAGER.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } + /// + /// Stores any pending enterprise API keys in the OS keyring. + /// + private static async Task StoreEnterpriseApiKeysAsync() + { + var pendingKeys = PendingEnterpriseApiKeys.GetAndClear(); + if (pendingKeys.Count == 0) + return; + + LOG.LogInformation($"Storing {pendingKeys.Count} enterprise API key(s) in the OS keyring."); + var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); + foreach (var pendingKey in pendingKeys) + { + try + { + // Create a temporary secret ID object for storing the key: + var secretId = new TemporarySecretId(pendingKey.SecretId, pendingKey.SecretName); + var result = await rustService.SetAPIKey(secretId, pendingKey.ApiKey, pendingKey.StoreType); + + if (result.Success) + LOG.LogDebug($"Successfully stored enterprise API key for '{pendingKey.SecretName}' in the OS keyring."); + else + LOG.LogWarning($"Failed to store enterprise API key for '{pendingKey.SecretName}': {result.Issue}"); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Exception while storing enterprise API key for '{pendingKey.SecretName}'."); + } + } + } + + /// + /// Temporary implementation of ISecretId for storing enterprise API keys. + /// + private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId; + /// /// Tries to initialize the UI text content of the plugin. /// @@ -60,6 +101,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: allow the user to add providers? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun); + + // Config: show administration settings? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun); // Config: preview features visibility ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun); @@ -100,4 +144,4 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT message = string.Empty; return true; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index 647c79bf..ffc6f5c0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Services; using Lua; @@ -13,6 +14,7 @@ namespace AIStudio.Tools.PluginSystem; /// public sealed record PluginConfigurationObject { + private static readonly RustService RUST_SERVICE = Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); @@ -159,7 +161,7 @@ public sealed record PluginConfigurationObject return true; } - + /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// @@ -168,37 +170,45 @@ public sealed record PluginConfigurationObject /// A selection expression to retrieve the configuration objects from the main configuration. /// A list of currently available plugins. /// A list of all existing configuration objects. + /// An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable. /// Returns true if the configuration was altered during cleanup; otherwise, false. - public static bool CleanLeftOverConfigurationObjects( + public static async Task CleanLeftOverConfigurationObjects( PluginConfigurationObjectType configObjectType, Expression>> configObjectSelection, IList availablePlugins, - IList configObjectList) where TClass : IConfigurationObject + IList configObjectList, + SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject { var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData); var leftOverObjects = new List(); foreach (var configuredObject in configuredObjects) { + // Only process objects that are based on enterprise configuration plugins (aka configuration plugins), + // as only those can be left over after a plugin was removed: if(!configuredObject.IsEnterpriseConfiguration) continue; + // From what plugin is this configuration object coming from? var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId; if(configObjectSourcePluginId == Guid.Empty) continue; + // Is the source plugin still available? If not, we can be pretty sure that this configuration object is left + // over and should be removed: var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId); if(templateSourcePlugin is null) { - LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing the chat template from the settings."); + LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing this object from the settings."); leftOverObjects.Add(configuredObject); } + // Is the configuration object still present in the configuration plugin? If not, it is also left over and should be removed: if(!configObjectList.Any(configObject => configObject.Type == configObjectType && configObject.ConfigPluginId == configObjectSourcePluginId && configObject.Id.ToString() == configuredObject.Id)) { - LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the chat template from the settings."); + LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the object from the settings."); leftOverObjects.Add(configuredObject); } } @@ -206,8 +216,20 @@ public sealed record PluginConfigurationObject // Remove collected items after enumeration to avoid modifying the collection during iteration: var wasConfigurationChanged = leftOverObjects.Count > 0; foreach (var item in leftOverObjects.Distinct()) + { configuredObjects.Remove(item); + // Delete the API key from the OS keyring if the removed object has one: + if(secretStoreType is not null && item is ISecretId secretId) + { + var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value); + if (deleteResult.Success) + LOG.LogInformation($"Successfully deleted API key for removed enterprise provider '{item.Name}' from the OS keyring."); + else + LOG.LogWarning($"Failed to delete API key for removed enterprise provider '{item.Name}' from the OS keyring: {deleteResult.Issue}"); + } + } + return wasConfigurationChanged; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index b2b45aba..4bfbe3a3 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -131,26 +131,26 @@ public static partial class PluginFactory // // Check LLM providers: - var wasConfigurationChanged = PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList); + var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER); // Check transcription providers: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER)) wasConfigurationChanged = true; // Check embedding providers: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) wasConfigurationChanged = true; // Check chat templates: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; - + // Check profiles: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; // Check document analysis policies: - if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; // Check for a preselected profile: @@ -168,6 +168,10 @@ public static partial class PluginFactory // Check for users allowed to added providers: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + + // Check for admin settings visibility: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; // Check for preview visibility: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS)) @@ -253,4 +257,4 @@ public static partial class PluginFactory return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio."); } } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs index 1fd30fb2..2c20ede0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs @@ -18,6 +18,29 @@ public static partial class PluginFactory public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN; + /// + /// Gets the enterprise encryption instance for decrypting API keys in configuration plugins. + /// + public static EnterpriseEncryption? EnterpriseEncryption { get; private set; } + + /// + /// Initializes the enterprise encryption service by reading the encryption secret + /// from the Windows Registry or environment variables. + /// + /// The Rust service to use for reading the encryption secret. + public static async Task InitializeEnterpriseEncryption(Services.RustService rustService) + { + LOG.LogInformation("Initializing enterprise encryption service..."); + var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret(); + var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger(); + EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret); + + if (EnterpriseEncryption.IsAvailable) + LOG.LogInformation("Enterprise encryption service is available."); + else + LOG.LogWarning("Enterprise encryption service is not available (no secret configured)."); + } + /// /// Set up the plugin factory. We will read the data directory from the settings manager. /// Afterward, we will create the plugins directory and the internal plugin directory. diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index 76931c0b..004d445a 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -65,4 +65,24 @@ public sealed partial class RustService var serverUrl = await result.Content.ReadAsStringAsync(); return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl; } + + /// + /// Tries to read the enterprise environment for the configuration encryption secret. + /// + /// + /// Returns an empty string when the environment is not set or the request fails. + /// Otherwise, the base64-encoded encryption secret. + /// + public async Task EnterpriseEnvConfigEncryptionSecret() + { + var result = await this.http.GetAsync("/system/enterprise/config/encryption_secret"); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to query the enterprise configuration encryption secret: '{result.StatusCode}'"); + return string.Empty; + } + + var encryptionSecret = await result.Content.ReadAsStringAsync(); + return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/packages.lock.json b/app/MindWork AI Studio/packages.lock.json index dba56e5c..7dff471e 100644 --- a/app/MindWork AI Studio/packages.lock.json +++ b/app/MindWork AI Studio/packages.lock.json @@ -62,6 +62,16 @@ "MudBlazor": "8.11.0" } }, + "Qdrant.Client": { + "type": "Direct", + "requested": "[1.16.1, )", + "resolved": "1.16.1", + "contentHash": "EJo50JXTdjY2JOUphCFLXoHukI/tz/ykLCmMnQHUjsKT22ZfL0XIdEziHOC3vjw2SOoY8WDVQ+AxixEonejOZA==", + "dependencies": { + "Google.Protobuf": "3.31.0", + "Grpc.Net.Client": "2.71.0" + } + }, "ReverseMarkdown": { "type": "Direct", "requested": "[5.0.0, )", @@ -76,6 +86,33 @@ "resolved": "3.2.449", "contentHash": "uA9sYDy4VepL3xwzBTLcP2LyuVYMt0ZIT3gaSiXvGoX15Ob+rOP+hGydhevlSVd+rFo+Y+VQFEHDuWU8HBW+XA==" }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.31.0", + "contentHash": "OZXSf6igaJBeo+kAzMhYF0R5zp0nRgf4G0Uis/IsGKACc4RGP9bQPLpHLengIFuASl0lY92utMB8rRpTx4TaOg==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "QquqUC37yxsDzd1QaDRsH2+uuznWPTS8CVE2Yzwl3CvU4geTNkolQXoVN812M2IwT6zpv3jsZRc9ExJFNFslTg==" + }, + "Grpc.Net.Client": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "U1vr20r5ngoT9nlb7wejF28EKN+taMhJsV9XtK9MkiepTZwnKxxiarriiMfCHuDAfPUm9XUjFMn/RIuJ4YY61w==", + "dependencies": { + "Grpc.Net.Common": "2.71.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0" + } + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "v0c8R97TwRYwNXlC8GyRXwYTCNufpDfUtj9la+wUrZFzVWkFJuNAltU+c0yI3zu0jl54k7en6u2WKgZgd57r2Q==", + "dependencies": { + "Grpc.Core.Api": "2.71.0" + } + }, "Markdig": { "type": "Transitive", "resolved": "0.41.3", diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index e4b9137b..977f7da9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -1,2 +1,6 @@ # v26.2.2, build 234 (2026-02-xx xx:xx UTC) -- Added a vector database (Qdrant) as a building block for our local RAG (retrieval-augmented generation) solution. Thank you very much, Paul (`PaulKoudelka`), for this major contribution. Note that our local RAG implementation remained in preview and has not yet been released; other building blocks are not yet ready. \ No newline at end of file +- Added a vector database (Qdrant) as a building block for our local RAG (retrieval-augmented generation) solution. Thank you very much, Paul (`PaulKoudelka`), for this major contribution. Note that our local RAG implementation remained in preview and has not yet been released; other building blocks are not yet ready. +- Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings. +- Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. +- Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. +- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. \ No newline at end of file diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 396308b8..39d4fbd2 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -27,6 +27,8 @@ The following keys and values (registry) and variables are checked and read: - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file. +- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. + Let's assume as example that `https://intranet.my-company.com:30100/ai-studio/configuration` is the server address and `9072b77d-ca81-40da-be6a-861da525ef7b` is the configuration ID. AI Studio will derive the following address from this information: `https://intranet.my-company.com:30100/ai-studio/configuration/9072b77d-ca81-40da-be6a-861da525ef7b.zip`. Important: The configuration ID will always be written in lowercase, even if it is configured in uppercase. If `9072B77D-CA81-40DA-BE6A-861DA525EF7B` is configured, the same address will be derived. Your web server must be configured accordingly. Finally, AI Studio will send a GET request and download the ZIP file. The ZIP file only contains the files necessary for the configuration. It's normal to include a file for an icon along with the actual configuration plugin. @@ -82,14 +84,61 @@ The latest example of an AI Studio configuration via configuration plugin can al Please note that the icon must be an SVG vector graphic. Raster graphics like PNGs, GIFs, and others aren’t supported. You can use the sample icon, which looks like a gear. Currently, you can configure the following things: -- Any number of self-hosted LLM providers (a combination of server and model), but currently only without API keys +- Any number of LLM providers (self-hosted or cloud providers with encrypted API keys) +- Any number of transcription providers for voice-to-text functionality +- Any number of embedding providers for RAG - The update behavior of AI Studio +- Various UI and feature settings (see the example configuration for details) All other settings can be made by the user themselves. If you need additional settings, feel free to create an issue in our planning repository: https://github.com/MindWorkAI/Planning/issues -In the coming months, we will allow more settings, such as: -- Using API keys for providers -- Configuration of embedding providers for RAG -- Configuration of data sources for RAG -- Configuration of chat templates -- Configuration of assistant plugins (for example, your own assistants for your company or specific departments) \ No newline at end of file +## Encrypted API Keys + +You can include encrypted API keys in your configuration plugins for cloud providers (like OpenAI, Anthropic) or secured on-premise models. This feature provides obfuscation to prevent casual exposure of API keys in configuration files. + +**Important Security Note:** This is obfuscation, not absolute security. Users with administrative access to their machines can potentially extract the decrypted API keys with sufficient effort. This feature is designed to: +- Prevent API keys from being visible in plaintext in configuration files +- Protect against accidental exposure when sharing or reviewing configurations +- Add a barrier against casual snooping + +### Setting Up Encrypted API Keys + +1. **Generate an encryption secret:** + In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string. + +2. **Deploy the encryption secret:** + Distribute the secret to all client machines via Group Policy (Windows Registry) or environment variables: + - Registry: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret` + - Environment: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` + + You must also deploy the same secret on the machine where you will export the encrypted API keys (step 3). + +3. **Export encrypted API keys from AI Studio:** + Once the encryption secret is deployed on your machine: + - Configure a provider with an API key in AI Studio's settings + - Click the export button for that provider + - If an API key is configured, you will be asked if you want to include the encrypted API key in the export + - The exported Lua code will contain the encrypted API key in the format `ENC:v1:` + +4. **Add encrypted keys to your configuration:** + Copy the exported configuration (including the encrypted API key) into your configuration plugin. + +### Example Configuration with Encrypted API Key + +```lua +CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { + ["Id"] = "9072b77d-ca81-40da-be6a-861da525ef7b", + ["InstanceName"] = "Corporate OpenAI GPT-4", + ["UsedLLMProvider"] = "OPEN_AI", + ["Host"] = "NONE", + ["Hostname"] = "", + ["APIKey"] = "ENC:v1:MTIzNDU2Nzg5MDEyMzQ1NkFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFla...", + ["AdditionalJsonApiParameters"] = "", + ["Model"] = { + ["Id"] = "gpt-4", + ["DisplayName"] = "GPT-4", + } +} +``` + +The API key will be automatically decrypted when the configuration is loaded and stored securely in the operating system's credential store (Windows Credential Manager / macOS Keychain). \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 48fb43c3..24a20bde 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -1046,6 +1046,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1056,6 +1065,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1063,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.5", "winapi", ] @@ -1073,6 +1094,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.6.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2082,7 +2113,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -2781,6 +2812,7 @@ dependencies = [ "cfg-if", "cipher", "crossbeam-channel", + "dirs", "file-format", "flexi_logger", "futures", @@ -2803,9 +2835,11 @@ dependencies = [ "sha2", "strum_macros", "sys-locale", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-window-state", + "tempfile", "tokio", "tokio-stream", "tracing-subscriber", @@ -2942,6 +2976,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -3084,9 +3127,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -3105,11 +3148,12 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.6.0", + "dispatch2", "objc2", ] @@ -3142,6 +3186,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.0" @@ -3256,6 +3310,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.0" @@ -3956,6 +4016,17 @@ dependencies = [ "thiserror 1.0.63", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.23" @@ -4897,6 +4968,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe840c5b1afe259a5657392a4dbb74473a14c8db999c3ec2f4ae812e028a94da" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5008,7 +5093,7 @@ dependencies = [ "unicode-segmentation", "uuid", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", "x11-dl", ] @@ -6028,7 +6113,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", ] [[package]] @@ -6125,7 +6210,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a" dependencies = [ - "windows-implement", + "windows-implement 0.39.0", "windows_aarch64_msvc 0.39.0", "windows_i686_gnu 0.39.0", "windows_i686_msvc 0.39.0", @@ -6142,6 +6227,18 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + [[package]] name = "windows-bindgen" version = "0.39.0" @@ -6152,6 +6249,15 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -6161,6 +6267,30 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.39.0" @@ -6171,6 +6301,28 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -6189,6 +6341,16 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -6379,6 +6541,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-tokens" version = "0.39.0" @@ -6728,7 +6899,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows 0.39.0", - "windows-implement", + "windows-implement 0.39.0", ] [[package]] diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 8c484ab8..6203cac0 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -119,6 +119,30 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { ) } +#[get("/system/enterprise/config/encryption_secret")] +pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { + // + // When we are on a Windows machine, we try to read the enterprise config from + // the Windows registry. In case we can't find the registry key, or we are on a + // macOS or Linux machine, we try to read the enterprise config from the + // environment variables. + // + // The registry key is: + // HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT + // + // In this registry key, we expect the following values: + // - config_encryption_secret + // + // The environment variable is: + // MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET + // + debug!("Trying to read the enterprise environment for the config encryption secret."); + get_enterprise_configuration( + "config_encryption_secret", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET", + ) +} + fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 77f4f032..647259f3 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -85,6 +85,7 @@ pub fn start_runtime_api() { crate::environment::read_enterprise_env_config_id, crate::environment::delete_enterprise_env_config_id, crate::environment::read_enterprise_env_config_server_url, + crate::environment::read_enterprise_env_config_encryption_secret, crate::file_data::extract_data, crate::log::get_log_paths, crate::log::log_event, From 7ae8f7b5eddb8c5db5eb4d7dc325298724518ce0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 12:37:45 +0100 Subject: [PATCH 10/34] Upgraded dependencies (#655) --- .../wwwroot/changelog/v26.2.2.md | 3 +- runtime/Cargo.lock | 110 +++++++----------- runtime/Cargo.toml | 10 +- 3 files changed, 47 insertions(+), 76 deletions(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 977f7da9..29f64e51 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -3,4 +3,5 @@ - Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings. - Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. -- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. \ No newline at end of file +- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. +- Upgraded dependencies. \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 24a20bde..5fad3be7 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -197,9 +197,12 @@ dependencies = [ [[package]] name = "atoi_simd" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4790f9e8961209112beb783d85449b508673cf4a6a419c8449b210743ac4dbe9" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" +dependencies = [ + "debug_unsafe", +] [[package]] name = "atomic" @@ -406,9 +409,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -458,9 +461,9 @@ dependencies = [ [[package]] name = "calamine" -version = "0.32.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41bdeb83af82cd9cb686a19ed7efc2f50a21c262610f51ce945a8528860725ce" +checksum = "96ae094b353c7810cd5efd2e69413ebb9354816138a387c09f7b90d4e826a49f" dependencies = [ "atoi_simd", "byteorder", @@ -470,7 +473,7 @@ dependencies = [ "log", "quick-xml 0.38.4", "serde", - "zip 4.2.0", + "zip 7.4.0", ] [[package]] @@ -789,9 +792,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -948,6 +951,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "debug_unsafe" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b" + [[package]] name = "deflate64" version = "0.1.9" @@ -970,12 +979,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1046,15 +1055,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs-next" version = "2.0.0" @@ -1065,18 +1065,6 @@ dependencies = [ "dirs-sys-next", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1084,7 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users 0.4.5", + "redox_users", "winapi", ] @@ -2807,12 +2795,11 @@ dependencies = [ "arboard", "async-stream", "base64 0.22.1", + "bytes", "calamine", "cbc", "cfg-if", "cipher", - "crossbeam-channel", - "dirs", "file-format", "flexi_logger", "futures", @@ -2828,7 +2815,6 @@ dependencies = [ "rand_chacha 0.9.0", "rcgen", "reqwest 0.13.1", - "ring", "rocket", "serde", "serde_json", @@ -2840,10 +2826,9 @@ dependencies = [ "tauri-build", "tauri-plugin-window-state", "tempfile", + "time", "tokio", "tokio-stream", - "tracing-subscriber", - "url", "windows-registry 0.6.1", ] @@ -3029,9 +3014,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3310,12 +3295,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "os_pipe" version = "1.2.0" @@ -4016,17 +3995,6 @@ dependencies = [ "thiserror 1.0.63", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror 2.0.12", -] - [[package]] name = "ref-cast" version = "1.0.23" @@ -5433,30 +5401,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa 1.0.11", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5748,6 +5716,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + [[package]] name = "typenum" version = "1.17.0" @@ -7144,15 +7118,15 @@ dependencies = [ [[package]] name = "zip" -version = "4.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap 2.7.0", "memchr", + "typed-path", "zopfli", ] diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index b7a4bac0..835e632f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -34,7 +34,7 @@ hmac = "0.12.1" sha2 = "0.10.8" rcgen = { version = "0.14.7", features = ["pem"] } file-format = "0.28.0" -calamine = "0.32.0" +calamine = "0.33.0" pdfium-render = "0.8.37" sys-locale = "0.3.2" cfg-if = "1.0.4" @@ -44,12 +44,8 @@ strum_macros = "0.27" sysinfo = "0.38.0" # Fixes security vulnerability downstream, where the upstream is not fixed yet: -url = "2.5.8" -ring = "0.17.14" -crossbeam-channel = "0.5.15" -tracing-subscriber = "0.3.20" -dirs = "6.0.0" - +time = "0.3.47" # -> Rocket +bytes = "1.11.1" # -> almost every dependency [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 From 50d4e7e6dcaae77b362e7982da537c9fac5f4030 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 13:04:19 +0100 Subject: [PATCH 11/34] Fixed workspace delete color (#656) --- app/MindWork AI Studio/Components/Workspaces.razor | 6 +++--- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index bdeb52ae..80a81f60 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -36,7 +36,7 @@ - + @@ -57,7 +57,7 @@ - + @@ -90,4 +90,4 @@ break; } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 29f64e51..0e024e75 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -4,4 +4,5 @@ - Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. +- Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Upgraded dependencies. \ No newline at end of file From 4eb58eb56d3751902f06af4c9f1f125c98ca8255 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 17:27:33 +0100 Subject: [PATCH 12/34] Fixed manual chat saving during streaming (#657) --- app/MindWork AI Studio/Components/ChatComponent.razor | 2 +- app/MindWork AI Studio/Components/ChatComponent.razor.cs | 4 ++++ app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 38409675..52b82b9b 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -66,7 +66,7 @@ @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) { - + } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index ed37e54d..c7bd4dce 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -912,6 +912,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable break; case Event.CHAT_STREAMING_DONE: + // Streaming mutates the last AI block over time. + // In manual storage mode, a save during streaming must not + // mark the final streamed state as already persisted. + this.hasUnsavedChanges = true; if(this.autoSaveEnabled) await this.SaveThread(); break; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 0e024e75..598bb828 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -5,4 +5,5 @@ - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. +- Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Upgraded dependencies. \ No newline at end of file From 891b90819b639ba17ac17e771f46448ec7746b91 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Feb 2026 17:46:38 +0100 Subject: [PATCH 13/34] Localized database information (#658) --- .../Assistants/I18N/allTexts.lua | 15 +++++++++++ .../Pages/Information.razor | 2 +- .../plugin.lua | 15 +++++++++++ .../plugin.lua | 15 +++++++++++ .../Tools/Databases/DatabaseClient.cs | 10 +++---- .../Qdrant/QdrantClientImplementation.cs | 26 +++++++++---------- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index e1188a53..ef169b14 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5833,6 +5833,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Storage size" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Reported version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Number of collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment." diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 32238d06..b857f80d 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -24,7 +24,7 @@ @this.VersionDatabase - + @foreach (var item in this.databaseDisplayInfo) { diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 9612b2ef..7d3503d2 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -5835,6 +5835,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Allen LLM-Anbietern vertrauen" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Speichergröße" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP-Port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Gemeldete Version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC-Port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Anzahl der Collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "Die zugehörigen Daten dürfen an keinen LLM-Anbieter gesendet werden. Das bedeutet, dass diese Datenquelle momentan nicht verwendet werden kann." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 4be2328f..6c2e30cf 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -5835,6 +5835,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T3893997203"] = " -- Trust all LLM providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "Trust all LLM providers" +-- Storage size +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Storage size" + +-- HTTP port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP port" + +-- Reported version +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Reported version" + +-- gRPC port +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC port" + +-- Number of collections +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::QDRANT::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Number of collections" + -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment." diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs index 5ea457ec..b50aafe1 100644 --- a/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClient.cs @@ -5,12 +5,12 @@ public abstract class DatabaseClient(string name, string path) public string Name => name; private string Path => path; - - protected ILogger? logger; + + private ILogger? logger; public abstract IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo(); - public string GetStorageSize() + protected string GetStorageSize() { if (string.IsNullOrWhiteSpace(this.Path)) { @@ -28,8 +28,8 @@ public abstract class DatabaseClient(string name, string path) var size = files.Sum(file => new FileInfo(file).Length); return FormatBytes(size); } - - public static string FormatBytes(long size) + + private static string FormatBytes(long size) { string[] suffixes = { "B", "KB", "MB", "GB", "TB", "PB" }; int suffixIndex = 0; diff --git a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs index 77ae3636..25f37253 100644 --- a/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/Qdrant/QdrantClientImplementation.cs @@ -1,10 +1,13 @@ using Qdrant.Client; using Qdrant.Client.Grpc; +using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.Databases.Qdrant; public class QdrantClientImplementation : DatabaseClient { + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(QdrantClientImplementation).Namespace, nameof(QdrantClientImplementation)); + private int HttpPort { get; } private int GrpcPort { get; } @@ -25,8 +28,8 @@ public class QdrantClientImplementation : DatabaseClient } private const string IP_ADDRESS = "localhost"; - - public QdrantClient CreateQdrantClient() + + private QdrantClient CreateQdrantClient() { var address = "https://" + IP_ADDRESS + ":" + this.GrpcPort; var channel = QdrantChannel.ForAddress(address, new ClientConfiguration @@ -38,13 +41,13 @@ public class QdrantClientImplementation : DatabaseClient return new QdrantClient(grpcClient); } - public async Task GetVersion() + private async Task GetVersion() { var operation = await this.GrpcClient.HealthAsync(); return "v"+operation.Version; } - public async Task GetCollectionsAmount() + private async Task GetCollectionsAmount() { var operation = await this.GrpcClient.ListCollectionsAsync(); return operation.Count.ToString(); @@ -52,15 +55,12 @@ public class QdrantClientImplementation : DatabaseClient public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() { - yield return ("HTTP port", this.HttpPort.ToString()); - yield return ("gRPC port", this.GrpcPort.ToString()); - yield return ("Extracted version", await this.GetVersion()); - yield return ("Storage size", $"{base.GetStorageSize()}"); - yield return ("Amount of collections", await this.GetCollectionsAmount()); + yield return (TB("HTTP port"), this.HttpPort.ToString()); + yield return (TB("gRPC port"), this.GrpcPort.ToString()); + yield return (TB("Reported version"), await this.GetVersion()); + yield return (TB("Storage size"), $"{this.GetStorageSize()}"); + yield return (TB("Number of collections"), await this.GetCollectionsAmount()); } - public override void Dispose() - { - this.GrpcClient.Dispose(); - } + public override void Dispose() => this.GrpcClient.Dispose(); } \ No newline at end of file From ea4e3f0199e7c413c8fc9df99c75fcef410e84fd Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 10 Feb 2026 15:23:56 +0100 Subject: [PATCH 14/34] Render source URL for plugins (#660) --- .../Assistants/I18N/allTexts.lua | 15 +++++-- app/MindWork AI Studio/Pages/Plugins.razor | 36 ++++++++++++---- app/MindWork AI Studio/Pages/Plugins.razor.cs | 4 +- .../Plugins/configuration/plugin.lua | 7 ++- .../plugin.lua | 15 +++++-- .../plugin.lua | 15 +++++-- .../Tools/PluginSystem/PluginBase.cs | 43 ++++++++++++++++++- .../wwwroot/changelog/v26.2.2.md | 1 + 8 files changed, 115 insertions(+), 21 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ef169b14..becac5b0 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5305,6 +5305,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" @@ -5317,6 +5320,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings" @@ -6037,15 +6043,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "The -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "The field DESCRIPTION does not exist or is not a valid string." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X)." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "The table AUTHORS is empty. At least one author must be specified." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string." diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index b5a39ef4..c1012744 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -63,15 +63,35 @@ - @if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION }) - { - var isEnabled = this.SettingsManager.IsPluginEnabled(context); - - - - } + + @if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION }) + { + var isEnabled = this.SettingsManager.IsPluginEnabled(context); + + + + } + + @if (context is { IsInternal: false } && !string.IsNullOrWhiteSpace(context.SourceURL)) + { + var sourceUrl = context.SourceURL; + var isSendingMail = IsSendingMail(sourceUrl); + if(isSendingMail) + { + + + + } + else + { + + + + } + } + - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 4eb6078c..36de6366 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -49,6 +49,8 @@ public partial class Plugins : MSGComponentBase await this.SettingsManager.StoreSettings(); await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } + + private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); #region Overrides of MSGComponentBase @@ -63,4 +65,4 @@ public partial class Plugins : MSGComponentBase } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 3e12a567..4c37375a 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -30,7 +30,12 @@ AUTHORS = {""} -- The support contact for the plugin: SUPPORT_CONTACT = "" --- The source URL for the plugin: +-- The source URL for the plugin. Can be a HTTP(S) URL or an mailto link. +-- You may link to an internal documentation page, a Git repository, or +-- to a support or wiki page. +-- +-- A mailto link could look like: +-- SOURCE_URL = "mailto:helpdesk@company.org?subject=Help" SOURCE_URL = "" -- The categories for the plugin: diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 7d3503d2..a5d01438 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -5307,6 +5307,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Interne Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Deaktivierte Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "E-Mail senden" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren" @@ -5319,6 +5322,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Aktivierte Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Aktionen" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Website öffnen" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Einstellungen" @@ -6039,15 +6045,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "Das -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "Das Feld DESCRIPTION existiert nicht oder ist keine gültige Zeichenkette." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "Das Feld SOURCE_URL ist keine gültige URL. Die URL muss mit 'http://' oder 'https://' beginnen." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "Das Feld VERSION ist keine gültige Versionsnummer. Die Versionsnummer muss als Zeichenkette im Format major.minor.patch (X.X.X) angegeben werden." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "Das Feld SOURCE_URL ist keine gültige URL. Die URL muss mit „http://“, „https://“ oder „mailto:“ beginnen." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "Die Tabelle AUTHORS ist leer. Es muss mindestens ein Autor angegeben werden." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "Das Feld SOURCE_URL ist keine gültige URL. Wenn die URL mit „mailto:“ beginnt, muss sie eine gültige E-Mail-Adresse als Empfänger enthalten." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "Das Feld SUPPORT_CONTACT ist leer. Der Support-Kontakt muss eine nicht-leere Zeichenkette sein." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 6c2e30cf..11e11579 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -5307,6 +5307,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T158493184"] = "Internal Plugins" -- Disabled Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1724138133"] = "Disabled Plugins" +-- Send a mail +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1999487139"] = "Send a mail" + -- Enable plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" @@ -5319,6 +5322,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins" -- Actions UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions" +-- Open website +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website" + -- Settings UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings" @@ -6039,15 +6045,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2262604281"] = "The -- The field DESCRIPTION does not exist or is not a valid string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T229488255"] = "The field DESCRIPTION does not exist or is not a valid string." --- The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2320984047"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'." - -- The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X). UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2538827536"] = "The field VERSION is not a valid version number. The version number must be formatted as string in the major.minor.patch format (X.X.X)." +-- The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2892057533"] = "The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'." + -- The table AUTHORS is empty. At least one author must be specified. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T2981832540"] = "The table AUTHORS is empty. At least one author must be specified." +-- The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3165663073"] = "The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient." + -- The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T3524814526"] = "The field SUPPORT_CONTACT is empty. The support contact must be a non-empty string." diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs index e6e8707a..afff3d35 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginBase.cs @@ -332,16 +332,55 @@ public abstract partial class PluginBase : IPluginMetadata return false; } - if (!url.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) && !url.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase)) + url = url.Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var sourceUri)) { url = string.Empty; - message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://' or 'https://'."); + message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); return false; } + + var isHttp = sourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + var isHttps = sourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + var isMailTo = sourceUri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase); + if (!isHttp && !isHttps && !isMailTo) + { + url = string.Empty; + message = TB("The field SOURCE_URL is not a valid URL. The URL must start with 'http://', 'https://', or 'mailto:'."); + return false; + } + + if (isMailTo) + { + var recipient = ExtractMailtoRecipient(url); + if (string.IsNullOrWhiteSpace(recipient)) + { + url = string.Empty; + message = TB("The field SOURCE_URL is not a valid URL. When the URL starts with 'mailto:', it must contain a valid email address as recipient."); + return false; + } + } + + url = sourceUri.ToString(); message = string.Empty; return true; } + + private static string ExtractMailtoRecipient(string rawUrl) + { + var separatorIndex = rawUrl.IndexOf(':'); + if (separatorIndex < 0 || separatorIndex + 1 >= rawUrl.Length) + return string.Empty; + + var schemeSpecificPart = rawUrl[(separatorIndex + 1)..]; + var queryStart = schemeSpecificPart.IndexOf('?'); + var recipient = queryStart >= 0 + ? schemeSpecificPart[..queryStart] + : schemeSpecificPart; + + return recipient.Trim(); + } /// /// Tries to read the categories of the plugin. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 598bb828..d0f5dc9a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -5,5 +5,6 @@ - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. +- Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Upgraded dependencies. \ No newline at end of file From 48f8cb32857ff2c1b1b1f89ac9f7fcc594add159 Mon Sep 17 00:00:00 2001 From: Oliver Kunc <36070570+OliverKunc@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:37:36 +0100 Subject: [PATCH 15/34] Fixed OpenAI Responses API by not discarding whitespace (#661) Co-authored-by: Thorsten Sommer --- .../Provider/OpenAI/ResponsesDeltaStreamLine.cs | 6 +++--- app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs index 5bad9c1b..0c1767d0 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesDeltaStreamLine.cs @@ -7,15 +7,15 @@ namespace AIStudio.Provider.OpenAI; /// The delta content of the response. public record ResponsesDeltaStreamLine( string Type, - string Delta) : IResponseStreamLine + string? Delta) : IResponseStreamLine { #region Implementation of IResponseStreamLine /// - public bool ContainsContent() => !string.IsNullOrWhiteSpace(this.Delta); + public bool ContainsContent() => this.Delta is not null; /// - public ContentStreamChunk GetContent() => new(this.Delta, this.GetSources()); + public ContentStreamChunk GetContent() => new(this.Delta ?? string.Empty, this.GetSources()); // // Please note that there are multiple options where LLM providers might stream sources: diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index d0f5dc9a..abbe60f4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -7,4 +7,5 @@ - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. +- Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. - Upgraded dependencies. \ No newline at end of file From 3671444d2862f9630b998ef4411f89575f335a32 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 15 Feb 2026 18:11:57 +0100 Subject: [PATCH 16/34] Support multiple enterprise configurations (#662) --- .../Assistants/I18N/allTexts.lua | 24 ++- .../Layout/MainLayout.razor.cs | 9 +- .../Pages/Information.razor | 138 ++++++++------ .../Pages/Information.razor.cs | 12 +- .../plugin.lua | 24 ++- .../plugin.lua | 24 ++- .../PluginSystem/PluginFactory.Loading.cs | 10 + .../Tools/Rust/EnterpriseConfig.cs | 3 + .../Services/EnterpriseEnvironmentService.cs | 173 +++++++++++------- .../Tools/Services/RustService.Enterprise.cs | 131 ++++++------- .../wwwroot/changelog/v26.2.2.md | 1 + documentation/Enterprise IT.md | 38 +++- runtime/src/environment.rs | 126 ++++++++++++- runtime/src/runtime_api.rs | 2 + 14 files changed, 479 insertions(+), 236 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index becac5b0..9e66386d 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5044,12 +5044,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." @@ -5059,9 +5059,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..." + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." @@ -5125,9 +5131,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" @@ -5191,6 +5194,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog" @@ -5224,6 +5230,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!" @@ -5233,9 +5242,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index af5f3a5b..08005e68 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -211,9 +211,12 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // // Check if there is an enterprise configuration plugin to download: // - var enterpriseEnvironment = this.MessageBus.CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT).FirstOrDefault(); - if (enterpriseEnvironment != default) - await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl); + var enterpriseEnvironments = this.MessageBus + .CheckDeferredMessages(Event.STARTUP_ENTERPRISE_ENVIRONMENT) + .Where(env => env != default) + .ToList(); + foreach (var env in enterpriseEnvironments) + await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl); // Initialize the enterprise encryption service for decrypting API keys: await PluginFactory.InitializeEnterpriseEncryption(this.RustService); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index b857f80d..5a964179 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -49,26 +49,33 @@ - @switch (EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive) + @switch (HasAnyActiveEnvironment) { - case false when this.configPlug is null: + case false when this.configPlugins.Count == 0: @T("This is a private AI Studio installation. It runs without an enterprise configuration.") break; - + case false: - @T("AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.") + @T("AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.") - -
- - @T("Configuration plugin ID:") @this.configPlug!.Id - -
-
+ @foreach (var plug in this.configPlugins) + { + +
+ + @plug.Name +
+
+ + @T("Configuration plugin ID:") @plug.Id + +
+
+ }
@@ -87,26 +94,30 @@ break; - case true when this.configPlug is null: + case true when this.configPlugins.Count == 0: - @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.") + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.") - -
- - @T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId - -
-
- - -
- - @T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl - -
-
+ @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) + { + +
+ + @T("Waiting for the configuration plugin...") +
+
+ + @T("Enterprise configuration ID:") @env.ConfigurationId + +
+
+ + @T("Configuration server:") @env.ConfigurationServerUrl + +
+
+ }
@@ -127,32 +138,45 @@ case true: - @T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.") + @T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.") - -
- - @T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId - -
-
- - -
- - @T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl - -
-
- - -
- - @T("Configuration plugin ID:") @this.configPlug!.Id - -
-
+ @foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive)) + { + var matchingPlugin = this.configPlugins.FirstOrDefault(p => p.Id == env.ConfigurationId); + +
+ @if (matchingPlugin is not null) + { + + @matchingPlugin.Name + } + else + { + + @T("ID mismatch: the plugin ID differs from the enterprise configuration ID.") + } +
+
+ + @T("Enterprise configuration ID:") @env.ConfigurationId + +
+
+ + @T("Configuration server:") @env.ConfigurationServerUrl + +
+ @if (matchingPlugin is not null) + { +
+ + @T("Configuration plugin ID:") @matchingPlugin.Id + +
+ } +
+ }
@@ -184,10 +208,10 @@ - + @T("Check for updates") - + @this.PandocButtonText @@ -195,7 +219,7 @@ - + @T("Discover MindWork AI's mission and vision on our official homepage.") @@ -236,14 +260,14 @@ @T("Startup log file") - + @T("Usage log file") - + diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index aa649a3c..a4eb5123 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -69,12 +69,14 @@ public partial class Information : MSGComponentBase private bool showDatabaseDetails; - private IPluginMetadata? configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION); + private List configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); private sealed record DatabaseDisplayInfo(string Label, string Value); private readonly List databaseDisplayInfo = new(); + private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive); + /// /// Determines whether the enterprise configuration has details that can be shown/hidden. /// Returns true if there are details available, false otherwise. @@ -83,16 +85,16 @@ public partial class Information : MSGComponentBase { get { - return EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive switch + return HasAnyActiveEnvironment switch { // Case 1: No enterprise config and no plugin - no details available - false when this.configPlug is null => false, + false when this.configPlugins.Count == 0 => false, // Case 2: Enterprise config with plugin but no central management - has details false => true, // Case 3: Enterprise config active but no plugin - has details - true when this.configPlug is null => true, + true when this.configPlugins.Count == 0 => true, // Case 4: Enterprise config active with plugin - has details true => true @@ -128,7 +130,7 @@ public partial class Information : MSGComponentBase switch (triggeredEvent) { case Event.PLUGINS_RELOADED: - this.configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION); + this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList(); await this.InvokeAsync(this.StateHasChanged); break; } diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index a5d01438..e8ef345b 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -5046,12 +5046,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startprotokollda -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Sehen Sie sich den Quellcode von AI Studio auf GitHub an – wir freuen uns über ihre Beiträge." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID-Konflikt: Die Plugin-ID stimmt nicht mit der ID der Unternehmenskonfiguration überein." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "Dies ist eine private AI Studio-Installation. Sie läuft ohne Unternehmenskonfiguration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist noch nicht verfügbar." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "Diese Bibliothek wird verwendet, um PDF-Dateien zu lesen. Das ist zum Beispiel notwendig, um PDFs als Datenquelle für einen Chat zu nutzen." @@ -5061,9 +5061,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Datenbankversion -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "Diese Bibliothek wird verwendet, um die MudBlazor-Bibliothek zu erweitern. Sie stellt zusätzliche Komponenten bereit, die nicht Teil der MudBlazor-Bibliothek sind." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Warten auf das Konfigurations-Plugin …" + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind aktiv." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant ist eine Vektordatenbank und Suchmaschine für Vektoren. Wir nutzen Qdrant, um lokales RAG (Retrieval-Augmented Generation) innerhalb von AI Studio zu realisieren. Vielen Dank für den Einsatz und die großartige Arbeit, die in Qdrant gesteckt wurde und weiterhin gesteckt wird." @@ -5127,9 +5133,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Konfigurationsse -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "Wir müssen Zufallszahlen erzeugen, z. B. um die Kommunikation zwischen der Benutzeroberfläche und der Laufzeitumgebung abzusichern. Die rand-Bibliothek eignet sich dafür hervorragend." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio läuft mit einer Unternehmenskonfiguration über ein Konfigurations-Plugin, ohne zentrale Konfigurationsverwaltung." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-Plugin-ID:" @@ -5193,6 +5196,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Erklärung" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind noch nicht verfügbar." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll" @@ -5226,6 +5232,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Informationen ü -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Verwendeter Rust-Compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio wird mit Unternehmenskonfigurationen unter Verwendung von Konfigurations-Plugins betrieben. Eine zentrale Konfigurationsverwaltung wird nicht eingesetzt." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri wird verwendet, um die Blazor-Benutzeroberfläche bereitzustellen. Es ist ein großartiges Projekt, das die Erstellung von Desktop-Anwendungen mit Webtechnologien ermöglicht. Ich liebe Tauri!" @@ -5235,9 +5244,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "Diese Bibliothek wird verwendet, um Excel- und OpenDocument-Tabellendateien zu lesen. Dies ist zum Beispiel notwendig, wenn Tabellen als Datenquelle für einen Chat verwendet werden sollen." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio läuft mit einer Unternehmenskonfiguration und einem Konfigurationsserver. Das Konfigurations-Plugin ist aktiv." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version erfüllt die Anforderungen nicht" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 11e11579..562cc2a6 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -5046,12 +5046,12 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startup log file -- Browse AI Studio's source code on GitHub — we welcome your contributions. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions." +-- ID mismatch: the plugin ID differs from the enterprise configuration ID. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID." + -- This is a private AI Studio installation. It runs without an enterprise configuration. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1209549230"] = "This is a private AI Studio installation. It runs without an enterprise configuration." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1282228996"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available." - -- This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1388816916"] = "This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat." @@ -5061,9 +5061,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1420062548"] = "Database version -- This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1421513382"] = "This library is used to extend the MudBlazor library. It provides additional components that are not part of the MudBlazor library." +-- Waiting for the configuration plugin... +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..." + -- Encryption secret: is not configured UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured" +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active." + -- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG -— retrieval-augmented generation -— within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." @@ -5127,9 +5133,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2272122662"] = "Configuration se -- We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2273492381"] = "We must generate random numbers, e.g., for securing the interprocess communication between the user interface and the runtime. The rand library is great for this purpose." --- AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2280402765"] = "AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management." - -- Configuration plugin ID: UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration plugin ID:" @@ -5193,6 +5196,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation" -- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software." +-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available." + -- Changelog UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog" @@ -5226,6 +5232,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3433065373"] = "Information abou -- Used Rust compiler UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3440211747"] = "Used Rust compiler" +-- AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management." + -- Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri! UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3494984593"] = "Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!" @@ -5235,9 +5244,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3563271893"] = "Motivation" -- This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3722989559"] = "This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat." --- AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3741877842"] = "AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active." - -- this version does not met the requirements UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version does not met the requirements" diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 4bfbe3a3..9565a833 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -103,6 +103,16 @@ public static partial class PluginFactory } LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')"); + + // For configuration plugins, validate that the plugin ID matches the enterprise config ID + // (the directory name under which the plugin was downloaded): + if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase)) + { + var directoryName = Path.GetFileName(pluginPath); + if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id) + LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID."); + } + AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath)); } catch (Exception e) diff --git a/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs b/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs new file mode 100644 index 00000000..bc6fb15e --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/EnterpriseConfig.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record EnterpriseConfig(string Id, string ServerUrl); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index 44645dc7..ec0ee648 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -4,7 +4,7 @@ namespace AIStudio.Tools.Services; public sealed class EnterpriseEnvironmentService(ILogger logger, RustService rustService) : BackgroundService { - public static EnterpriseEnvironment CURRENT_ENVIRONMENT; + public static List CURRENT_ENVIRONMENTS = []; #if DEBUG private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6); @@ -33,84 +33,125 @@ public sealed class EnterpriseEnvironmentService(ILogger plugin.Id == enterpriseRemoveConfigId); - if (enterpriseRemoveConfigId != Guid.Empty && isPlugin2RemoveInUse) - { - logger.LogWarning("The enterprise environment configuration ID '{EnterpriseRemoveConfigId}' must be removed.", enterpriseRemoveConfigId); - PluginFactory.RemovePluginAsync(enterpriseRemoveConfigId); - } - string? enterpriseConfigServerUrl; + // + // Step 1: Handle deletions first. + // + List deleteConfigIds; try { - enterpriseConfigServerUrl = await rustService.EnterpriseEnvConfigServerUrl(); + deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds(); } catch (Exception e) { - logger.LogError(e, "Failed to fetch the enterprise configuration server URL from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigServerUrl failed"); + logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service."); + await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed"); return; } - Guid enterpriseConfigId; - try + foreach (var deleteId in deleteConfigIds) { - enterpriseConfigId = await rustService.EnterpriseEnvConfigId(); - } - catch (Exception e) - { - logger.LogError(e, "Failed to fetch the enterprise configuration ID from the Rust service."); - await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigId failed"); - return; - } - - var etag = await PluginFactory.DetermineConfigPluginETagAsync(enterpriseConfigId, enterpriseConfigServerUrl); - var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag); - if (CURRENT_ENVIRONMENT != nextEnterpriseEnvironment) - { - logger.LogInformation("The enterprise environment has changed. Updating the current environment."); - CURRENT_ENVIRONMENT = nextEnterpriseEnvironment; - - switch (enterpriseConfigServerUrl) + var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId); + if (isPluginInUse) { - case null when enterpriseConfigId == Guid.Empty: - case not null when string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty: - logger.LogInformation("AI Studio runs without an enterprise configuration."); - break; - - case null: - logger.LogWarning("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}'), but the configuration server URL is not set.", enterpriseConfigId); - break; - - case not null when !string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty: - logger.LogWarning("AI Studio runs with an enterprise configuration server URL ('{EnterpriseConfigServerUrl}'), but the configuration ID is not set.", enterpriseConfigServerUrl); - break; - - default: - logger.LogInformation("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}') and configuration server URL ('{EnterpriseConfigServerUrl}').", enterpriseConfigId, enterpriseConfigServerUrl); - - if(isFirstRun) - MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag)); - else - await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl); - break; + logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId); + PluginFactory.RemovePluginAsync(deleteId); } } - else - logger.LogInformation("The enterprise environment has not changed. No update required."); + + // + // Step 2: Fetch all active configurations. + // + List fetchedConfigs; + try + { + fetchedConfigs = await rustService.EnterpriseEnvConfigs(); + } + catch (Exception e) + { + logger.LogError(e, "Failed to fetch the enterprise configurations from the Rust service."); + await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigs failed"); + return; + } + + // + // Step 3: Determine ETags and build the next environment list. + // + var nextEnvironments = new List(); + foreach (var config in fetchedConfigs) + { + if (!config.IsActive) + { + logger.LogWarning("Skipping inactive enterprise configuration with ID '{ConfigId}'. There is either no valid server URL or config ID set.", config.ConfigurationId); + continue; + } + + var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl); + nextEnvironments.Add(config with { ETag = etag }); + } + + if (nextEnvironments.Count == 0) + { + if (CURRENT_ENVIRONMENTS.Count > 0) + { + logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs."); + + // Remove plugins for configs that were previously active: + foreach (var oldEnv in CURRENT_ENVIRONMENTS) + { + var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); + if (isPluginInUse) + PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); + } + } + else + logger.LogInformation("AI Studio runs without any enterprise configurations."); + + CURRENT_ENVIRONMENTS = []; + return; + } + + // + // Step 4: Compare with current environments and process changes. + // + var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet(); + var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet(); + + // Remove plugins for configs that are no longer present: + foreach (var oldEnv in CURRENT_ENVIRONMENTS) + { + if (!nextIds.Contains(oldEnv.ConfigurationId)) + { + logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId); + var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId); + if (isPluginInUse) + PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId); + } + } + + // Process new or changed configs: + foreach (var nextEnv in nextEnvironments) + { + var currentEnv = CURRENT_ENVIRONMENTS.FirstOrDefault(e => e.ConfigurationId == nextEnv.ConfigurationId); + if (currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag). + { + logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId); + continue; + } + + var isNew = !currentIds.Contains(nextEnv.ConfigurationId); + if(isNew) + logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + else + logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId); + + if (isFirstRun) + MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv); + else + await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl); + } + + CURRENT_ENVIRONMENTS = nextEnvironments; } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index 004d445a..cf8fbc26 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -1,71 +1,9 @@ -namespace AIStudio.Tools.Services; +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; public sealed partial class RustService { - /// - /// Tries to read the enterprise environment for the current user's configuration ID. - /// - /// - /// Returns the empty Guid when the environment is not set or the request fails. - /// Otherwise, the configuration ID. - /// - public async Task EnterpriseEnvConfigId() - { - var result = await this.http.GetAsync("/system/enterprise/config/id"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration ID: '{result.StatusCode}'"); - return Guid.Empty; - } - - Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId); - return configurationId; - } - - /// - /// Tries to read the enterprise environment for a configuration ID, which must be removed. - /// - /// - /// Removing a configuration ID is necessary when the user moved to another department or - /// left the company, or when the configuration ID is no longer valid. - /// - /// - /// Returns the empty Guid when the environment is not set or the request fails. - /// Otherwise, the configuration ID. - /// - public async Task EnterpriseEnvRemoveConfigId() - { - var result = await this.http.DeleteAsync("/system/enterprise/config/id"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration ID for removal: '{result.StatusCode}'"); - return Guid.Empty; - } - - Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId); - return configurationId; - } - - /// - /// Tries to read the enterprise environment for the current user's configuration server URL. - /// - /// - /// Returns null when the environment is not set or the request fails. - /// Otherwise, the configuration server URL. - /// - public async Task EnterpriseEnvConfigServerUrl() - { - var result = await this.http.GetAsync("/system/enterprise/config/server"); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to query the enterprise configuration server URL: '{result.StatusCode}'"); - return string.Empty; - } - - var serverUrl = await result.Content.ReadAsStringAsync(); - return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl; - } - /// /// Tries to read the enterprise environment for the configuration encryption secret. /// @@ -85,4 +23,67 @@ public sealed partial class RustService var encryptionSecret = await result.Content.ReadAsStringAsync(); return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret; } + + /// + /// Reads all enterprise configurations (multi-config support). + /// + /// + /// Returns a list of enterprise environments parsed from the Rust runtime. + /// The ETag is not yet determined; callers must resolve it separately. + /// + public async Task> EnterpriseEnvConfigs() + { + var result = await this.http.GetAsync("/system/enterprise/configs"); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'"); + return []; + } + + var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); + if (configs is null) + return []; + + var environments = new List(); + foreach (var config in configs) + { + if (Guid.TryParse(config.Id, out var id)) + environments.Add(new EnterpriseEnvironment(config.ServerUrl, id, null)); + else + this.logger!.LogWarning($"Skipping enterprise config with invalid ID: '{config.Id}'."); + } + + return environments; + } + + /// + /// Reads all enterprise configuration IDs that should be deleted. + /// + /// + /// Returns a list of GUIDs representing configuration IDs to remove. + /// + public async Task> EnterpriseEnvDeleteConfigIds() + { + var result = await this.http.GetAsync("/system/enterprise/delete-configs"); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'"); + return []; + } + + var ids = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); + if (ids is null) + return []; + + var guids = new List(); + foreach (var idStr in ids) + { + if (Guid.TryParse(idStr, out var id)) + guids.Add(id); + else + this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'."); + } + + return guids; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index abbe60f4..af8eff4b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -3,6 +3,7 @@ - Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings. - Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled. - Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled. +- Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index 39d4fbd2..dd62dd77 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -13,13 +13,33 @@ Do you want to manage MindWork AI Studio in a corporate environment or within an AI Studio checks about every 16 minutes to see if the configuration ID, the server for the configuration, or the configuration itself has changed. If it finds any changes, it loads the updated configuration from the server and applies it right away. ## Configure the devices -So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees’ devices. Currently, the following options are available: +So that MindWork AI Studio knows where to load which configuration, this information must be provided as metadata on employees' devices. Currently, the following options are available: - **Registry** (only available for Microsoft Windows): On Windows devices, AI Studio first tries to read the information from the registry. The registry information can be managed and distributed centrally as a so-called Group Policy Object (GPO). - **Environment variables**: On all operating systems (on Windows as a fallback after the registry), AI Studio tries to read the configuration metadata from environment variables. -The following keys and values (registry) and variables are checked and read: +### Multiple configurations (recommended) + +AI Studio supports loading multiple enterprise configurations simultaneously. This enables hierarchical configuration schemes, e.g., organization-wide settings combined with department-specific settings. The following keys and variables are used: + +- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). + +- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_ids` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS`: One or more configuration IDs that should be removed, separated by `;`. The format is: `id1;id2;id3`. This is helpful if an employee moves to a different department or leaves the organization. + +- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret. + +**Example:** To configure two enterprise configurations (one for the organization and one for a department): + +``` +MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https://intranet.my-company.com:30100/ai-studio/configuration;a1b2c3d4-e5f6-7890-abcd-ef1234567890@https://intranet.my-company.com:30100/ai-studio/department-config +``` + +**Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority. + +### Single configuration (legacy) + +The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations: - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person. @@ -29,11 +49,13 @@ The following keys and values (registry) and variables are checked and read: - Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. +### How configurations are downloaded + Let's assume as example that `https://intranet.my-company.com:30100/ai-studio/configuration` is the server address and `9072b77d-ca81-40da-be6a-861da525ef7b` is the configuration ID. AI Studio will derive the following address from this information: `https://intranet.my-company.com:30100/ai-studio/configuration/9072b77d-ca81-40da-be6a-861da525ef7b.zip`. Important: The configuration ID will always be written in lowercase, even if it is configured in uppercase. If `9072B77D-CA81-40DA-BE6A-861DA525EF7B` is configured, the same address will be derived. Your web server must be configured accordingly. Finally, AI Studio will send a GET request and download the ZIP file. The ZIP file only contains the files necessary for the configuration. It's normal to include a file for an icon along with the actual configuration plugin. -Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this. +Approximately every 16 minutes, AI Studio checks the metadata of the ZIP file by reading the [ETag](https://en.wikipedia.org/wiki/HTTP_ETag). When the ETag was not changed, no download will be performed. Make sure that your web server supports this. When using multiple configurations, each configuration is checked independently. ## Configure the configuration web server @@ -75,6 +97,16 @@ intranet.my-company.com:30100 { } ``` +## Important: Plugin ID must match the enterprise configuration ID + +The `ID` field inside your configuration plugin (the Lua file) **must** be identical to the enterprise configuration ID used in the registry or environment variable. AI Studio uses this ID to match downloaded configurations to their plugins. If the IDs do not match, AI Studio will log a warning and the configuration may not be displayed correctly on the Information page. + +For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861da525ef7b`, then your plugin must declare: + +```lua +ID = "9072b77d-ca81-40da-be6a-861da525ef7b" +``` + ## Example AI Studio configuration The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files: diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 6203cac0..f3ccdc60 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,7 +1,9 @@ use std::env; use std::sync::OnceLock; -use log::{debug, warn}; +use log::{debug, info, warn}; use rocket::{delete, get}; +use rocket::serde::json::Json; +use serde::Serialize; use sys_locale::get_locale; use crate::api_token::APIToken; @@ -143,23 +145,127 @@ pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String ) } +/// Represents a single enterprise configuration entry with an ID and server URL. +#[derive(Serialize)] +pub struct EnterpriseConfig { + pub id: String, + pub server_url: String, +} + +/// Returns all enterprise configurations. Collects configurations from both the +/// new multi-config format (`id1@url1;id2@url2`) and the legacy single-config +/// environment variables, merging them into one list. Duplicates (by ID) are +/// skipped — the first occurrence wins. +#[get("/system/enterprise/configs")] +pub fn read_enterprise_configs(_token: APIToken) -> Json> { + info!("Trying to read the enterprise environment for all configurations."); + + let mut configs: Vec = Vec::new(); + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + + // Read the new combined format: + let combined = get_enterprise_configuration( + "configs", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS", + ); + + if !combined.is_empty() { + // Parse the new format: id1@url1;id2@url2;... + for entry in combined.split(';') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + + // Split at the first '@' (GUIDs never contain '@'): + if let Some((id, url)) = entry.split_once('@') { + let id = id.trim().to_lowercase(); + let url = url.trim().to_string(); + if !id.is_empty() && !url.is_empty() && seen_ids.insert(id.clone()) { + configs.push(EnterpriseConfig { id, server_url: url }); + } + } + } + } + + // Also read the legacy single-config variables: + let config_id = get_enterprise_configuration( + "config_id", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID", + ); + + let config_server_url = get_enterprise_configuration( + "config_server_url", + "MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL", + ); + + if !config_id.is_empty() && !config_server_url.is_empty() { + let id = config_id.trim().to_lowercase(); + if seen_ids.insert(id.clone()) { + configs.push(EnterpriseConfig { id, server_url: config_server_url }); + } + } + + Json(configs) +} + +/// Returns all enterprise configuration IDs that should be deleted. Supports the new +/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable. +#[get("/system/enterprise/delete-configs")] +pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json> { + info!("Trying to read the enterprise environment for configuration IDs to delete."); + + let mut ids: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + // Read the new combined format: + let combined = get_enterprise_configuration( + "delete_config_ids", + "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS", + ); + + if !combined.is_empty() { + for id in combined.split(';') { + let id = id.trim().to_lowercase(); + if !id.is_empty() && seen.insert(id.clone()) { + ids.push(id); + } + } + } + + // Also read the legacy single-delete variable: + let delete_id = get_enterprise_configuration( + "delete_config_id", + "MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID", + ); + + if !delete_id.is_empty() { + let id = delete_id.trim().to_lowercase(); + if seen.insert(id.clone()) { + ids.push(id); + } + } + + Json(ids) +} + fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { cfg_if::cfg_if! { if #[cfg(target_os = "windows")] { - debug!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT' or environment variables."); + info!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}' or the environment variable '{}'.", _reg_value, env_name); use windows_registry::*; let key_path = r"Software\github\MindWork AI Studio\Enterprise IT"; let key = match CURRENT_USER.open(key_path) { Ok(key) => key, Err(_) => { - debug!(r"Could not read the registry key HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT. Falling back to environment variables."); + info!(r"Could not read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}'. Falling back to the environment variable '{}'.", _reg_value, env_name); return match env::var(env_name) { Ok(val) => { - debug!("Falling back to the environment variable '{}' was successful.", env_name); + info!("Falling back to the environment variable '{}' was successful.", env_name); val }, Err(_) => { - debug!("Falling back to the environment variable '{}' was not successful.", env_name); + info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); "".to_string() }, } @@ -169,14 +275,14 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { match key.get_string(_reg_value) { Ok(val) => val, Err(_) => { - debug!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to environment variables.", _reg_value); + info!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to the environment variable '{}'.", _reg_value, env_name); match env::var(env_name) { Ok(val) => { - debug!("Falling back to the environment variable '{}' was successful.", env_name); + info!("Falling back to the environment variable '{}' was successful.", env_name); val }, Err(_) => { - debug!("Falling back to the environment variable '{}' was not successful.", env_name); + info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name); "".to_string() } } @@ -184,11 +290,11 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { } } else { // In the case of macOS or Linux, we just read the environment variable: - debug!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name); + info!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name); match env::var(env_name) { Ok(val) => val, Err(_) => { - debug!("The environment variable '{}' was not found.", env_name); + info!("The environment variable '{}' was not found. It seems that there is no enterprise environment available.", env_name); "".to_string() } } diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 647259f3..3a4c1f9c 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -86,6 +86,8 @@ pub fn start_runtime_api() { crate::environment::delete_enterprise_env_config_id, crate::environment::read_enterprise_env_config_server_url, crate::environment::read_enterprise_env_config_encryption_secret, + crate::environment::read_enterprise_configs, + crate::environment::read_enterprise_delete_config_ids, crate::file_data::extract_data, crate::log::get_log_paths, crate::log::log_event, From 53f657da71ff0e88f3db554286958b3379e74938 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 12:13:36 +0100 Subject: [PATCH 17/34] Improved the user language detection, especially for Linux (#663) --- .../Settings/SettingsManager.cs | 44 +++++-- .../wwwroot/changelog/v26.2.2.md | 1 + runtime/src/environment.rs | 115 +++++++++++++++++- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index 3b4ea704..d4bfc7e3 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -172,17 +172,31 @@ public sealed class SettingsManager { case LangBehavior.AUTO: var languageCode = await this.rustService.ReadUserLanguage(); - var languagePlugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x is ILanguagePlugin langPlug && langPlug.IETFTag == languageCode); - if (languagePlugin is null) + var languagePlugins = PluginFactory.RunningPlugins.OfType().ToList(); + + if (!string.IsNullOrWhiteSpace(languageCode)) { - this.logger.LogWarning($"The language plugin for the language '{languageCode}' is not available."); - return PluginFactory.BaseLanguage; + var exactMatch = languagePlugins.FirstOrDefault(x => string.Equals(x.IETFTag, languageCode, StringComparison.OrdinalIgnoreCase)); + if (exactMatch is not null) + return exactMatch; + + var primaryLanguage = GetPrimaryLanguage(languageCode); + if (!string.IsNullOrWhiteSpace(primaryLanguage)) + { + var primaryLanguageMatch = languagePlugins + .Where(x => string.Equals(GetPrimaryLanguage(x.IETFTag), primaryLanguage, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.IETFTag, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (primaryLanguageMatch is not null) + { + this.logger.LogWarning($"No exact language plugin found for '{languageCode}'. Use language fallback '{primaryLanguageMatch.IETFTag}'."); + return primaryLanguageMatch; + } + } } - if (languagePlugin is ILanguagePlugin langPlugin) - return langPlugin; - - this.logger.LogError("The language plugin is not a language plugin."); + this.logger.LogWarning($"The language plugin for the language '{languageCode}' (normalized='{languageCode}') is not available."); return PluginFactory.BaseLanguage; case LangBehavior.MANUAL: @@ -204,6 +218,18 @@ public sealed class SettingsManager this.logger.LogError("The language behavior is unknown."); return PluginFactory.BaseLanguage; } + + private static string GetPrimaryLanguage(string localeTag) + { + if (string.IsNullOrWhiteSpace(localeTag)) + return string.Empty; + + var separatorIndex = localeTag.IndexOf('-'); + if (separatorIndex < 0) + return localeTag; + + return localeTag[..separatorIndex]; + } [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false) @@ -365,4 +391,4 @@ public sealed class SettingsManager // Return the full name of the property, including the class name: return $"{typeof(TIn).Name}.{memberExpr.Member.Name}"; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index af8eff4b..5823c1cf 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -7,6 +7,7 @@ - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. +- Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. - Upgraded dependencies. \ No newline at end of file diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index f3ccdc60..478fbff4 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -7,6 +7,8 @@ use serde::Serialize; use sys_locale::get_locale; use crate::api_token::APIToken; +const DEFAULT_LANGUAGE: &str = "en-US"; + /// The data directory where the application stores its data. pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); @@ -41,12 +43,115 @@ pub fn is_prod() -> bool { !is_dev() } +fn normalize_locale_tag(locale: &str) -> Option { + let trimmed = locale.trim(); + if trimmed.is_empty() { + return None; + } + + let without_encoding = trimmed + .split('.') + .next() + .unwrap_or(trimmed) + .split('@') + .next() + .unwrap_or(trimmed) + .trim(); + + if without_encoding.is_empty() { + return None; + } + + let normalized_delimiters = without_encoding.replace('_', "-"); + let mut segments = normalized_delimiters + .split('-') + .filter(|segment| !segment.is_empty()); + + let language = segments.next()?; + if language.eq_ignore_ascii_case("c") || language.eq_ignore_ascii_case("posix") { + return None; + } + + let language = language.to_ascii_lowercase(); + if language.len() < 2 || !language.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + if let Some(region) = segments.next() { + if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) { + return Some(format!("{}-{}", language, region.to_ascii_uppercase())); + } + } + + Some(language) +} + +#[cfg(target_os = "linux")] +fn read_locale_from_environment() -> Option { + if let Ok(language) = env::var("LANGUAGE") { + for candidate in language.split(':') { + if let Some(locale) = normalize_locale_tag(candidate) { + info!("Detected user language from Linux environment variable 'LANGUAGE': '{}'.", locale); + return Some(locale); + } + } + } + + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = env::var(key) { + if let Some(locale) = normalize_locale_tag(&value) { + info!("Detected user language from Linux environment variable '{}': '{}'.", key, locale); + return Some(locale); + } + } + } + + None +} + +#[cfg(not(target_os = "linux"))] +fn read_locale_from_environment() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::normalize_locale_tag; + + #[test] + fn normalize_locale_tag_supports_common_linux_formats() { + assert_eq!(normalize_locale_tag("de_DE.UTF-8"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de_DE@euro"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de"), Some(String::from("de"))); + assert_eq!(normalize_locale_tag("en-US"), Some(String::from("en-US"))); + } + + #[test] + fn normalize_locale_tag_rejects_non_language_locales() { + assert_eq!(normalize_locale_tag("C"), None); + assert_eq!(normalize_locale_tag("C.UTF-8"), None); + assert_eq!(normalize_locale_tag("POSIX"), None); + assert_eq!(normalize_locale_tag(""), None); + } +} + #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { - get_locale().unwrap_or_else(|| { - warn!("Could not determine the system language. Use default 'en-US'."); - String::from("en-US") - }) + if let Some(locale) = get_locale() { + if let Some(normalized_locale) = normalize_locale_tag(&locale) { + info!("Detected user language from sys-locale: '{}'.", normalized_locale); + return normalized_locale; + } + + warn!("sys-locale returned an unusable locale value: '{}'.", locale); + } + + if let Some(locale) = read_locale_from_environment() { + return locale; + } + + warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE); + String::from(DEFAULT_LANGUAGE) } #[get("/system/enterprise/config/id")] @@ -300,4 +405,4 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { } } } -} \ No newline at end of file +} From 4562ed1c6af0672222ddd0f99dada585371173bf Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 13:04:16 +0100 Subject: [PATCH 18/34] Updated README and other texts (#664) --- README.md | 1 + .../Assistants/I18N/allTexts.lua | 28 +++++++++++++---- .../Components/Motivation.razor | 14 +++++++-- .../Components/Vision.razor.cs | 1 + app/MindWork AI Studio/Pages/Home.razor.cs | 1 + .../plugin.lua | 30 +++++++++++++++---- .../plugin.lua | 28 +++++++++++++---- 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 363ec8aa..624cbfc8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provi **Key advantages:** - **Free of charge**: The app is free to use, both for personal and commercial purposes. +- **Democratization of AI**: We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. - **Independence**: You are not tied to any single provider. Instead, you can choose the providers that best suit your needs. Right now, we support: - [OpenAI](https://openai.com/) (GPT5, GPT4.1, o1, o3, o4, etc.) - [Perplexity](https://www.perplexity.ai/) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 9e66386d..0c4509e8 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1804,21 +1804,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard" @@ -2434,9 +2440,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats." @@ -4981,6 +4993,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage" @@ -4990,6 +5005,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started" diff --git a/app/MindWork AI Studio/Components/Motivation.razor b/app/MindWork AI Studio/Components/Motivation.razor index a8242f22..eae68519 100644 --- a/app/MindWork AI Studio/Components/Motivation.razor +++ b/app/MindWork AI Studio/Components/Motivation.razor @@ -1,6 +1,6 @@ @inherits MSGComponentBase - @T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time.") + @T("Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community.") @@ -28,5 +28,13 @@ - @T("Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices.") - \ No newline at end of file + @T("Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design.") + + + + @T("Democratization of AI") + + + + @T("We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge.") + diff --git a/app/MindWork AI Studio/Components/Vision.razor.cs b/app/MindWork AI Studio/Components/Vision.razor.cs index b81c084a..12a4f820 100644 --- a/app/MindWork AI Studio/Components/Vision.razor.cs +++ b/app/MindWork AI Studio/Components/Vision.razor.cs @@ -19,6 +19,7 @@ public partial class Vision : MSGComponentBase this.itemsVision = [ new(T("Meet your needs"), T("Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.")), + new(T("Democratization of AI"), T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.")), new(T("Integrating your data"), T("You'll be able to integrate your data into AI Studio, like your PDF or Office files, or your Markdown notes.")), new(T("Integration of enterprise data"), T("It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.")), new(T("Useful assistants"), T("We'll develop more assistants for everyday tasks.")), diff --git a/app/MindWork AI Studio/Pages/Home.razor.cs b/app/MindWork AI Studio/Pages/Home.razor.cs index 7facd6e3..610d22b0 100644 --- a/app/MindWork AI Studio/Pages/Home.razor.cs +++ b/app/MindWork AI Studio/Pages/Home.razor.cs @@ -31,6 +31,7 @@ public partial class Home : MSGComponentBase { this.itemsAdvantages = [ new(this.T("Free of charge"), this.T("The app is free to use, both for personal and commercial purposes.")), + new(this.T("Democratization of AI"), this.T("We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models.")), new(this.T("Independence"), this.T("You are not tied to any single provider. Instead, you might choose the provider that best suits your needs. Right now, we support OpenAI (GPT5, o1, etc.), Perplexity, Mistral, Anthropic (Claude), Google Gemini, xAI (Grok), DeepSeek, Alibaba Cloud (Qwen), OpenRouter, Hugging Face, and self-hosted models using vLLM, llama.cpp, ollama, LM Studio, Groq, or Fireworks. For scientists and employees of research institutions, we also support Helmholtz and GWDG AI services. These are available through federated logins like eduGAIN to all 18 Helmholtz Centers, the Max Planck Society, most German, and many international universities.")), new(this.T("Assistants"), this.T("You just want to quickly translate a text? AI Studio has so-called assistants for such and other tasks. No prompting is necessary when working with these assistants.")), new(this.T("Unrestricted usage"), this.T("Unlike services like ChatGPT, which impose limits after intensive use, MindWork AI Studio offers unlimited usage through the providers API.")), diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index e8ef345b..d95f1a6a 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -1798,7 +1798,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T527187983"] = " UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEPANDOCDEPENDENCY::T986578435"] = "Pandoc installieren" -- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologiestapel für den Bau solch robuster Anwendungen erwiesen." +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologie-Stack für den Bau solch robuster Anwendungen erwiesen." -- Limitations of Existing Solutions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränkungen bestehender Lösungen" @@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Einschränku -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Persönliche Bedürfnisse und Einschränkungen von Webdiensten" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Demokratisierung von KI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "Während ich nach passenden Lösungen suchte, stieß ich auf eine Desktop-Anwendung namens Anything LLM. Leider konnte sie meine spezifischen Anforderungen nicht erfüllen und entsprach auch nicht dem Benutzeroberflächendesign, das ich mir vorgestellt hatte. Für macOS gab es zwar mehrere Apps, die meiner Vorstellung ähnelten, aber sie waren allesamt kostenpflichtige Lösungen mit unklarer Herkunft. Die Identität der Entwickler und die Ursprünge dieser Apps waren nicht ersichtlich, was erhebliche Sicherheitsbedenken hervorrief. Berichte von Nutzern über gestohlene API-Schlüssel und unerwünschte Abbuchungen verstärkten meine Bedenken zusätzlich." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der ursprüngliche Entwickler von MindWork AI Studio. Die Motivation zur Entwicklung dieser App entstand aus mehreren wichtigen Bedürfnissen und Beobachtungen, die ich im Laufe der Zeit gemacht habe." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Mit MindWork AI Studio möchte ich ein sicheres, flexibles und benutzerfreundliches Werkzeug bereitstellen, das für ein breites Publikum geeignet ist, ohne Kompromisse bei Funktionalität oder Design einzugehen. Diese App ist das Ergebnis meines Wunsches, persönliche Anforderungen zu erfüllen, bestehende Lücken auf dem Markt zu schließen und innovative Entwicklungsmethoden zu präsentieren." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "Wir möchten auch zur Demokratisierung von KI beitragen. MindWork AI Studio läuft selbst auf kostengünstiger Hardware, einschließlich Computern für rund 100 € wie dem Raspberry Pi. Dadurch sind die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Für Ihre ersten Schritte können Sie mit lokalen LLMs beginnen oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Sich auf Webdienste wie ChatGPT zu verlassen, war für mich keine nachhaltige Lösung. Ich brauchte eine KI, die auch direkt auf Dateien auf meinem Gerät zugreifen kann – eine Funktion, die Webdienste aus Sicherheits- und Datenschutzgründen grundsätzlich nicht bieten. Zwar hätte ich mir eine eigene Lösung in Python programmieren können, aber das wäre für den Alltag zu umständlich gewesen. Noch wichtiger war mir, eine Lösung zu entwickeln, die jeder nutzen kann, ganz ohne Programmierkenntnisse." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hallo, mein Name ist Thorsten Sommer und ich bin der initiale Entwickler von MindWork AI Studio. Ich habe dieses Projekt auf Grundlage von Bedürfnissen und Beobachtungen gestartet, die ich im Laufe der Zeit gemacht habe. Heute haben wir ein Kernteam von Entwicklern und Unterstützung aus der Open-Source-Community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Plattformübergreifende und moderne Entwicklung" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Heute verfolgt unser Team das Ziel, ein sicheres, flexibles und benutzerfreundliches Tool bereitzustellen, das eine breite Zielgruppe anspricht, ohne dabei Kompromisse bei Funktionalität oder dem Design einzugehen." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Kopiert den Inhalt in die Zwischenablage" @@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "Sie können ihre -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "Bald wird es möglich sein, Daten aus dem Firmennetzwerk über eine festgelegte Schnittstelle (External Retrieval Interface, kurz ERI) zu integrieren. Dafür wird voraussichtlich Entwicklungsaufwand seitens der jeweiligen Organisation nötig sein." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Demokratisierung von KI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Was auch immer ihr Beruf oder ihre Aufgabe ist, MindWork AI Studio möchte ihre Bedürfnisse erfüllen: Egal, ob Sie Projektmanager, Wissenschaftler, Künstler, Autor, Softwareentwickler oder Spieleentwickler sind." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "Sie können ihre E-Mail-Postfächer mit AI Studio verbinden. Die KI liest ihre E-Mails und benachrichtigt Sie über wichtige Ereignisse. Außerdem haben Sie in ihren Chats Zugriff auf das Wissen aus ihren E-Mails." @@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "Sie zahlen nur für das, -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistenten" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr vollständiger Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unbeschränkte Nutzung" @@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Einführung" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Demokratisierung von KI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Los geht's" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 562cc2a6..688cb8d0 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -1806,21 +1806,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1086130692"] = "Limitations -- Personal Needs and Limitations of Web Services UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1839655973"] = "Personal Needs and Limitations of Web Services" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1986314327"] = "Democratization of AI" + -- While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3552777197"] = "While exploring available solutions, I found a desktop application called Anything LLM. Unfortunately, it fell short of meeting my specific requirements and lacked the user interface design I envisioned. For macOS, there were several apps similar to what I had in mind, but they were all commercial solutions shrouded in uncertainty. The developers' identities and the origins of these apps were unclear, raising significant security concerns. Reports from users about stolen API keys and unwanted charges only amplified my reservations." --- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3569462457"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. The motivation behind developing this app stems from several crucial needs and observations I made over time." - --- Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3622193740"] = "Through MindWork AI Studio, I aim to provide a secure, flexible, and user-friendly tool that caters to a wider audience without compromising on functionality or design. This app is the culmination of my desire to meet personal requirements, address existing gaps in the market, and showcase innovative development practices." +-- We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T3672974243"] = "We also want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs for your first steps or use affordable cloud models. MindWork AI Studio itself is available free of charge." -- Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T372007989"] = "Relying on web services like ChatGPT was not a sustainable solution for me. I needed an AI that could also access files directly on my device, a functionality web services inherently lack due to security and privacy constraints. Although I could have scripted something in Python to meet my needs, this approach was too cumbersome for daily use. More importantly, I wanted to develop a solution that anyone could use without needing any programming knowledge." +-- Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T483341611"] = "Hello, my name is Thorsten Sommer, and I am the initial creator of MindWork AI Studio. I started this project based on several crucial needs and observations I made over time. Today, we have a core team of developers and support from the open-source community." + -- Cross-Platform and Modern Development UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T843057510"] = "Cross-Platform and Modern Development" +-- Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T904941692"] = "Today, our team aims to provide a secure, flexible, and user-friendly tool that serves a broad audience without compromising on functionality or design." + -- Copies the content to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MUDCOPYCLIPBOARDBUTTON::T12948066"] = "Copies the content to the clipboard" @@ -2436,9 +2442,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "You'll be able t -- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question." +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Democratization of AI" + -- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer." +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge." + -- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats." @@ -4983,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "You only pay for what yo -- Assistants UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1614176092"] = "Assistants" +-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 EUR such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1628689293"] = "We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models." + -- Unrestricted usage UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1686815996"] = "Unrestricted usage" @@ -4992,6 +5007,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1702902297"] = "Introduction" -- Vision UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1892426825"] = "Vision" +-- Democratization of AI +UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1986314327"] = "Democratization of AI" + -- Let's get started UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Let's get started" From 6e33c361dc4f17f551257435b65b690daf66be65 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 13:36:53 +0100 Subject: [PATCH 19/34] Improved single-input dialog (#665) --- .../Dialogs/SingleInputDialog.razor | 16 +++++++++++++--- .../Dialogs/SingleInputDialog.razor.cs | 16 +++++++++++++++- .../wwwroot/changelog/v26.2.2.md | 1 + 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor index 23c0537f..31f23f17 100644 --- a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor +++ b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor @@ -1,11 +1,21 @@ @inherits MSGComponentBase - + @this.Message - + @@ -16,4 +26,4 @@ @this.ConfirmText - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs index 01c5be54..c858b38c 100644 --- a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs @@ -1,6 +1,7 @@ using AIStudio.Components; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; namespace AIStudio.Dialogs; @@ -57,6 +58,19 @@ public partial class SingleInputDialog : MSGComponentBase private void Cancel() => this.MudDialog.Cancel(); + private async Task HandleUserInputKeyDown(KeyboardEventArgs keyEvent) + { + var key = keyEvent.Key.ToLowerInvariant(); + var code = keyEvent.Code.ToLowerInvariant(); + if (key is not "enter" && code is not "enter" and not "numpadenter") + return; + + if (keyEvent is { AltKey: true } or { CtrlKey: true } or { MetaKey: true }) + return; + + await this.Confirm(); + } + private async Task Confirm() { await this.form.Validate(); @@ -65,4 +79,4 @@ public partial class SingleInputDialog : MSGComponentBase this.MudDialog.Close(DialogResult.Ok(this.UserInput)); } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index 5823c1cf..1cab7ec4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -6,6 +6,7 @@ - Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details. - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. +- Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. - Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. From b445600a52bce6507a41e2fbb252407a0eb03f68 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 19 Feb 2026 20:43:47 +0100 Subject: [PATCH 20/34] 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 21/34] 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 22/34] 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 23/34] 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 24/34] 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 25/34] 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 26/34] 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 27/34] 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 28/34] 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 29/34] 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 30/34] 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 31/34] 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 32/34] 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 33/34] 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. From 906d9ba058865010d7c3eb0e0f7d92748fa5fbb5 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 5 Mar 2026 18:37:18 +0100 Subject: [PATCH 34/34] Improved workspace performance (#680) --- .../Assistants/I18N/allTexts.lua | 13 +- .../Components/ChatComponent.razor.cs | 79 ++- .../Components/TreeItemType.cs | 1 + .../Components/Workspaces.razor | 201 ++++--- .../Components/Workspaces.razor.cs | 551 ++++++++--------- .../Dialogs/WorkspaceSelectionDialog.razor.cs | 24 +- app/MindWork AI Studio/Pages/Chat.razor | 9 + app/MindWork AI Studio/Pages/Chat.razor.cs | 8 + .../plugin.lua | 13 +- .../plugin.lua | 13 +- .../Tools/Services/TemporaryChatService.cs | 1 + .../Tools/WorkspaceBehaviour.cs | 564 +++++++++++++++--- .../Tools/WorkspaceTreeCacheSnapshot.cs | 3 + .../Tools/WorkspaceTreeChat.cs | 4 + .../Tools/WorkspaceTreeWorkspace.cs | 3 + .../wwwroot/changelog/v26.3.1.md | 1 + 16 files changed, 994 insertions(+), 494 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/WorkspaceTreeCacheSnapshot.cs create mode 100644 app/MindWork AI Studio/Tools/WorkspaceTreeChat.cs create mode 100644 app/MindWork AI Studio/Tools/WorkspaceTreeWorkspace.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 02233950..eeb90c5b 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2557,8 +2557,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" --- Unnamed workspace -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unnamed workspace" +-- Loading chats... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..." -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete" @@ -2614,9 +2614,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." --- Unnamed chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unnamed chat" - -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -4981,6 +4978,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one co -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" +-- Reload your workspaces +UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Reload your workspaces" + -- Hide your workspaces UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces" @@ -6519,3 +6519,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" + +-- Unnamed chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index c7bd4dce..9c2b38a0 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -56,6 +56,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private bool autoSaveEnabled; private string currentWorkspaceName = string.Empty; private Guid currentWorkspaceId = Guid.Empty; + private Guid currentChatThreadId = Guid.Empty; private CancellationTokenSource? cancellationTokenSource; private HashSet chatDocumentPaths = []; @@ -197,8 +198,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // if (this.ChatThread is not null) { + this.currentChatThreadId = this.ChatThread.ChatId; this.currentWorkspaceId = this.ChatThread.WorkspaceId; - this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); + this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); this.WorkspaceName(this.currentWorkspaceName); } @@ -214,12 +216,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.mustStoreChat = false; if(this.Workspaces is not null) - await this.Workspaces.StoreChat(this.ChatThread); + await this.Workspaces.StoreChatAsync(this.ChatThread); else - await WorkspaceBehaviour.StoreChat(this.ChatThread); + await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); this.currentWorkspaceId = this.ChatThread.WorkspaceId; - this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); + this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); this.WorkspaceName(this.currentWorkspaceName); } @@ -227,14 +229,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { this.Logger.LogInformation($"Try to load the chat '{this.loadChat.ChatId}' now."); this.mustLoadChat = false; - this.ChatThread = await WorkspaceBehaviour.LoadChat(this.loadChat); + this.ChatThread = await WorkspaceBehaviour.LoadChatAsync(this.loadChat); if(this.ChatThread is not null) { await this.ChatThreadChanged.InvokeAsync(this.ChatThread); this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully."); - this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); + this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); this.WorkspaceName(this.currentWorkspaceName); await this.SelectProviderWhenLoadingChat(); } @@ -260,7 +262,49 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await base.OnAfterRenderAsync(firstRender); } + protected override async Task OnParametersSetAsync() + { + await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await base.OnParametersSetAsync(); + } + #endregion + + private async Task SyncWorkspaceHeaderWithChatThreadAsync() + { + if (this.ChatThread is null) + { + if (this.currentChatThreadId != Guid.Empty || this.currentWorkspaceId != Guid.Empty || !string.IsNullOrWhiteSpace(this.currentWorkspaceName)) + { + this.currentChatThreadId = Guid.Empty; + this.currentWorkspaceId = Guid.Empty; + this.currentWorkspaceName = string.Empty; + this.WorkspaceName(this.currentWorkspaceName); + } + + return; + } + + // Guard: If ChatThread ID and WorkspaceId haven't changed, skip entirely. + // Using ID-based comparison instead of name-based to correctly handle + // temporary chats where the workspace name is always empty. + if (this.currentChatThreadId == this.ChatThread.ChatId + && this.currentWorkspaceId == this.ChatThread.WorkspaceId) + return; + + this.currentChatThreadId = this.ChatThread.ChatId; + this.currentWorkspaceId = this.ChatThread.WorkspaceId; + var loadedWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); + + // Only notify the parent when the name actually changed to prevent + // an infinite render loop: WorkspaceName → UpdateWorkspaceName → + // StateHasChanged → re-render → OnParametersSetAsync → WorkspaceName → ... + if (this.currentWorkspaceName != loadedWorkspaceName) + { + this.currentWorkspaceName = loadedWorkspaceName; + this.WorkspaceName(this.currentWorkspaceName); + } + } private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE; @@ -428,8 +472,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) return; - // We need to blur the focus away from the input field - // to be able to clear the field: + // Blur the focus away from the input field to be able to clear it: await this.inputField.BlurAsync(); // Create a new chat thread if necessary: @@ -520,8 +563,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Clear the input field: await this.inputField.FocusAsync(); + this.userInput = string.Empty; this.chatDocumentPaths.Clear(); + await this.inputField.BlurAsync(); // Enable the stream state for the chat component: @@ -583,9 +628,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // the workspace gets updated automatically when the chat is saved. // if (this.Workspaces is not null) - await this.Workspaces.StoreChat(this.ChatThread); + await this.Workspaces.StoreChatAsync(this.ChatThread); else - await WorkspaceBehaviour.StoreChat(this.ChatThread); + await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); this.hasUnsavedChanges = false; } @@ -621,9 +666,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.ChatThread.WorkspaceId.ToString(), this.ChatThread.ChatId.ToString()); if(this.Workspaces is null) - await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); + await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); else - await this.Workspaces.DeleteChat(chatPath, askForConfirmation: false, unloadChat: true); + await this.Workspaces.DeleteChatAsync(chatPath, askForConfirmation: false, unloadChat: true); } // @@ -665,6 +710,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // to reset the chat thread: // this.ChatThread = null; + this.currentChatThreadId = Guid.Empty; this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceName = string.Empty; this.WorkspaceName(this.currentWorkspaceName); @@ -739,13 +785,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable return; // Delete the chat from the current workspace or the temporary storage: - await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); + await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); this.ChatThread!.WorkspaceId = workspaceId; await this.SaveThread(); this.currentWorkspaceId = this.ChatThread.WorkspaceId; - this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); + this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); this.WorkspaceName(this.currentWorkspaceName); } @@ -758,12 +804,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (this.ChatThread is not null) { this.currentWorkspaceId = this.ChatThread.WorkspaceId; - this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); + this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId); this.WorkspaceName(this.currentWorkspaceName); + this.currentChatThreadId = this.ChatThread.ChatId; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); } else { + this.currentChatThreadId = Guid.Empty; this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceName = string.Empty; this.WorkspaceName(this.currentWorkspaceName); @@ -785,6 +833,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.isStreaming = false; this.hasUnsavedChanges = false; this.userInput = string.Empty; + this.currentChatThreadId = Guid.Empty; this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceName = string.Empty; diff --git a/app/MindWork AI Studio/Components/TreeItemType.cs b/app/MindWork AI Studio/Components/TreeItemType.cs index f43823b4..35028056 100644 --- a/app/MindWork AI Studio/Components/TreeItemType.cs +++ b/app/MindWork AI Studio/Components/TreeItemType.cs @@ -4,6 +4,7 @@ public enum TreeItemType { NONE, + LOADING, CHAT, WORKSPACE, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 80a81f60..56e5e59e 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -1,93 +1,114 @@ @inherits MSGComponentBase - - - @switch (item.Value) +@if (this.isInitialLoading) +{ + + + @for (var i = 0; i < 10; i++) { - case TreeDivider: -
  • - -
  • - break; - - case TreeItemData treeItem: - @if (treeItem.Type is TreeItemType.CHAT) - { - - -
    - - @if (string.IsNullOrWhiteSpace(treeItem.Text)) - { - @T("Empty chat") - } - else - { - @treeItem.ShortenedText - } - -
    - - - - - - - - - - - - -
    -
    -
    -
    - } - else if (treeItem.Type is TreeItemType.WORKSPACE) - { - - -
    - - @treeItem.Text - -
    - - - - - - - -
    -
    -
    -
    - } - else - { - - -
    - - @treeItem.Text - -
    -
    -
    - } - break; - - case TreeButton treeButton: -
  • -
    -
    - - @treeButton.Text - -
    -
  • - break; + } -
    -
    + +} +else +{ + + + @switch (item.Value) + { + case TreeDivider: +
  • + +
  • + break; + + case TreeItemData treeItem: + @if (treeItem.Type is TreeItemType.LOADING) + { + + + + + + } + else if (treeItem.Type is TreeItemType.CHAT) + { + + +
    + + @if (string.IsNullOrWhiteSpace(treeItem.Text)) + { + @T("Empty chat") + } + else + { + @treeItem.ShortenedText + } + +
    + + + + + + + + + + + + +
    +
    +
    +
    + } + else if (treeItem.Type is TreeItemType.WORKSPACE) + { + + +
    + + @treeItem.Text + +
    + + + + + + + +
    +
    +
    +
    + } + else + { + + +
    + + @treeItem.Text + +
    +
    +
    + } + break; + + case TreeButton treeButton: +
  • +
    +
    + + @treeButton.Text + +
    +
  • + break; + } +
    +
    +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index f3564e65..106d5719 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.Json; using AIStudio.Chat; @@ -29,31 +29,64 @@ public partial class Workspaces : MSGComponentBase public bool ExpandRootNodes { get; set; } = true; private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom; + private readonly SemaphoreSlim treeLoadingSemaphore = new(1, 1); + private readonly List> treeItems = []; + private readonly HashSet loadingWorkspaceChatLists = []; - private readonly List> treeItems = new(); + private CancellationTokenSource? prefetchCancellationTokenSource; + private bool isInitialLoading = true; + private bool isDisposed; #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - - // - // Notice: In order to get the server-based loading to work, we need to respect the following rules: - // - We must have initial tree items - // - Those initial tree items cannot have children - // - When assigning the tree items to the MudTreeViewItem component, we must set the Value property to the value of the item - // - // We won't await the loading of the tree items here, - // to avoid blocking the UI thread: - _ = this.LoadTreeItems(); + _ = this.LoadTreeItemsAsync(startPrefetch: true); } #endregion - private async Task LoadTreeItems() + private async Task LoadTreeItemsAsync(bool startPrefetch = true, bool forceReload = false) + { + await this.treeLoadingSemaphore.WaitAsync(); + try + { + if (this.isDisposed) + return; + + if (forceReload) + await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync(); + + var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); + this.BuildTreeItems(snapshot); + this.isInitialLoading = false; + } + finally + { + this.treeLoadingSemaphore.Release(); + } + + await this.SafeStateHasChanged(); + + if (startPrefetch) + await this.StartPrefetchAsync(); + } + + private void BuildTreeItems(WorkspaceTreeCacheSnapshot snapshot) { this.treeItems.Clear(); + + var workspaceChildren = new List>(); + foreach (var workspace in snapshot.Workspaces) + workspaceChildren.Add(this.CreateWorkspaceTreeItem(workspace)); + + workspaceChildren.Add(new TreeItemData + { + Expandable = false, + Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"), Icons.Material.Filled.LibraryAdd, this.AddWorkspaceAsync), + }); + this.treeItems.Add(new TreeItemData { Expanded = this.ExpandRootNodes, @@ -66,7 +99,7 @@ public partial class Workspaces : MSGComponentBase Icon = Icons.Material.Filled.Folder, Expandable = true, Path = "root", - Children = await this.LoadWorkspaces(), + Children = workspaceChildren, }, }); @@ -76,7 +109,10 @@ public partial class Workspaces : MSGComponentBase Value = new TreeDivider(), }); - await this.InvokeAsync(this.StateHasChanged); + var temporaryChatsChildren = new List>(); + foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime)) + temporaryChatsChildren.Add(CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); + this.treeItems.Add(new TreeItemData { Expanded = this.ExpandRootNodes, @@ -89,234 +125,219 @@ public partial class Workspaces : MSGComponentBase Icon = Icons.Material.Filled.Timer, Expandable = true, Path = "temp", - Children = await this.LoadTemporaryChats(), + Children = temporaryChatsChildren, }, }); - + } + + private TreeItemData CreateWorkspaceTreeItem(WorkspaceTreeWorkspace workspace) + { + var children = new List>(); + if (workspace.ChatsLoaded) + { + foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime)) + children.Add(CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); + } + else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId)) + children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath)); + + children.Add(new TreeItemData + { + Expandable = false, + Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"), Icons.Material.Filled.AddComment, () => this.AddChatAsync(workspace.WorkspacePath)), + }); + + return new TreeItemData + { + Expandable = true, + Value = new TreeItemData + { + Type = TreeItemType.WORKSPACE, + Depth = 1, + Branch = WorkspaceBranch.WORKSPACES, + Text = workspace.Name, + Icon = Icons.Material.Filled.Description, + Expandable = true, + Path = workspace.WorkspacePath, + Children = children, + }, + }; + } + + private IReadOnlyCollection> CreateLoadingRows(string workspacePath) + { + return + [ + this.CreateLoadingTreeItem(workspacePath, "loading_1"), + this.CreateLoadingTreeItem(workspacePath, "loading_2"), + this.CreateLoadingTreeItem(workspacePath, "loading_3"), + ]; + } + + private TreeItemData CreateLoadingTreeItem(string workspacePath, string suffix) + { + return new TreeItemData + { + Expandable = false, + Value = new TreeItemData + { + Type = TreeItemType.LOADING, + Depth = 2, + Branch = WorkspaceBranch.WORKSPACES, + Text = T("Loading chats..."), + Icon = Icons.Material.Filled.HourglassTop, + Expandable = false, + Path = Path.Join(workspacePath, suffix), + }, + }; + } + + private static TreeItemData CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) + { + return new TreeItemData + { + Expandable = false, + Value = new TreeItemData + { + Type = TreeItemType.CHAT, + Depth = depth, + Branch = branch, + Text = chat.Name, + Icon = icon, + Expandable = false, + Path = chat.ChatPath, + LastEditTime = chat.LastEditTime, + }, + }; + } + + private async Task SafeStateHasChanged() + { + if (this.isDisposed) + return; + await this.InvokeAsync(this.StateHasChanged); } - private async Task>> LoadTemporaryChats() + private async Task StartPrefetchAsync() { - var tempChildren = new List(); - - // Get the temp root directory: - var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats"); - - // Ensure the directory exists: - Directory.CreateDirectory(temporaryDirectories); - - // Enumerate the chat directories: - foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories)) + if (this.prefetchCancellationTokenSource is not null) { - // Read or create the `name` file (self-heal): - var chatNamePath = Path.Join(tempChatDirPath, "name"); - string chatName; - try + await this.prefetchCancellationTokenSource.CancelAsync(); + this.prefetchCancellationTokenSource.Dispose(); + } + + this.prefetchCancellationTokenSource = new CancellationTokenSource(); + await this.PrefetchWorkspaceChatsAsync(this.prefetchCancellationTokenSource.Token); + } + + private async Task PrefetchWorkspaceChatsAsync(CancellationToken cancellationToken) + { + try + { + await WorkspaceBehaviour.TryPrefetchRemainingChatsAsync(async _ => { - if (!File.Exists(chatNamePath)) - { - chatName = T("Unnamed chat"); - await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8); - } - else - { - chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8); - if (string.IsNullOrWhiteSpace(chatName)) - { - chatName = T("Unnamed chat"); - await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8); - } - } - } - catch - { - chatName = T("Unnamed chat"); - } - - // Read the last change time of the chat: - var chatThreadPath = Path.Join(tempChatDirPath, "thread.json"); - var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath); - - tempChildren.Add(new TreeItemData - { - Type = TreeItemType.CHAT, - Depth = 1, - Branch = WorkspaceBranch.TEMPORARY_CHATS, - Text = chatName, - Icon = Icons.Material.Filled.Timer, - Expandable = false, - Path = tempChatDirPath, - LastEditTime = lastEditTime, - }); + if (this.isDisposed || cancellationToken.IsCancellationRequested) + return; + + await this.LoadTreeItemsAsync(startPrefetch: false); + }, cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when the component is hidden or disposed. + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed while prefetching workspace chats."); + } + } + + private async Task OnWorkspaceClicked(TreeItemData treeItem) + { + if (treeItem.Type is not TreeItemType.WORKSPACE) + return; + + if (!Guid.TryParse(Path.GetFileName(treeItem.Path), out var workspaceId)) + return; + + await this.EnsureWorkspaceChatsLoadedAsync(workspaceId); + } + + private async Task EnsureWorkspaceChatsLoadedAsync(Guid workspaceId) + { + var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); + var hasWorkspace = false; + var chatsLoaded = false; + + foreach (var workspace in snapshot.Workspaces) + { + if (workspace.WorkspaceId != workspaceId) + continue; + + hasWorkspace = true; + chatsLoaded = workspace.ChatsLoaded; + break; + } + + if (!hasWorkspace || chatsLoaded || !this.loadingWorkspaceChatLists.Add(workspaceId)) + return; + + await this.LoadTreeItemsAsync(startPrefetch: false); + + try + { + await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspaceId); + } + finally + { + this.loadingWorkspaceChatLists.Remove(workspaceId); + } + + await this.LoadTreeItemsAsync(startPrefetch: false); + } + + public async Task ForceRefreshFromDiskAsync() + { + if (this.prefetchCancellationTokenSource is not null) + { + await this.prefetchCancellationTokenSource.CancelAsync(); + this.prefetchCancellationTokenSource.Dispose(); + this.prefetchCancellationTokenSource = null; } - var result = new List>(tempChildren.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData - { - Expandable = false, - Value = n, - })); - return result; - } - - private async Task>> LoadWorkspaces() - { - var workspaces = new List>(); + this.loadingWorkspaceChatLists.Clear(); + this.isInitialLoading = true; - // - // Search for workspace folders in the data directory: - // - - // Get the workspace root directory: - var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces"); - - // Ensure the directory exists: - Directory.CreateDirectory(workspaceDirectories); - - // Enumerate the workspace directories: - foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories)) - { - // Read or create the `name` file (self-heal): - var workspaceNamePath = Path.Join(workspaceDirPath, "name"); - string workspaceName; - try - { - if (!File.Exists(workspaceNamePath)) - { - workspaceName = T("Unnamed workspace"); - await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); - } - else - { - workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); - if (string.IsNullOrWhiteSpace(workspaceName)) - { - workspaceName = T("Unnamed workspace"); - await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); - } - } - } - catch - { - workspaceName = T("Unnamed workspace"); - } - - workspaces.Add(new TreeItemData - { - Expandable = true, - Value = new TreeItemData - { - Type = TreeItemType.WORKSPACE, - Depth = 1, - Branch = WorkspaceBranch.WORKSPACES, - Text = workspaceName, - Icon = Icons.Material.Filled.Description, - Expandable = true, - Path = workspaceDirPath, - Children = await this.LoadWorkspaceChats(workspaceDirPath), - }, - }); - } - - workspaces.Add(new TreeItemData - { - Expandable = false, - Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"),Icons.Material.Filled.LibraryAdd, this.AddWorkspace), - }); - return workspaces; + await this.SafeStateHasChanged(); + await this.LoadTreeItemsAsync(startPrefetch: true, forceReload: true); } - private async Task>> LoadWorkspaceChats(string workspacePath) + public async Task StoreChatAsync(ChatThread chat, bool reloadTreeItems = false) { - var workspaceChats = new List(); + await WorkspaceBehaviour.StoreChatAsync(chat); - // Enumerate the workspace directory: - foreach (var chatPath in Directory.EnumerateDirectories(workspacePath)) - { - // Read or create the `name` file (self-heal): - var chatNamePath = Path.Join(chatPath, "name"); - string chatName; - try - { - if (!File.Exists(chatNamePath)) - { - chatName = T("Unnamed chat"); - await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8); - } - else - { - chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8); - if (string.IsNullOrWhiteSpace(chatName)) - { - chatName = T("Unnamed chat"); - await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8); - } - } - } - catch - { - chatName = T("Unnamed chat"); - } - - // Read the last change time of the chat: - var chatThreadPath = Path.Join(chatPath, "thread.json"); - var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath); - - workspaceChats.Add(new TreeItemData - { - Type = TreeItemType.CHAT, - Depth = 2, - Branch = WorkspaceBranch.WORKSPACES, - Text = chatName, - Icon = Icons.Material.Filled.Chat, - Expandable = false, - Path = chatPath, - LastEditTime = lastEditTime, - }); - } + if (reloadTreeItems) + this.loadingWorkspaceChatLists.Clear(); - var result = new List>(workspaceChats.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData - { - Expandable = false, - Value = n, - })); - - result.Add(new() - { - Expandable = false, - Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"),Icons.Material.Filled.AddComment, () => this.AddChat(workspacePath)), - }); - - return result; + await this.LoadTreeItemsAsync(startPrefetch: false); } - public async Task StoreChat(ChatThread chat, bool reloadTreeItems = true) + private async Task LoadChatAsync(string? chatPath, bool switchToChat) { - await WorkspaceBehaviour.StoreChat(chat); - - // Reload the tree items: - if(reloadTreeItems) - await this.LoadTreeItems(); - - this.StateHasChanged(); - } - - private async Task LoadChat(string? chatPath, bool switchToChat) - { - if(string.IsNullOrWhiteSpace(chatPath)) + if (string.IsNullOrWhiteSpace(chatPath)) return null; - if(!Directory.Exists(chatPath)) + if (!Directory.Exists(chatPath)) return null; - // Check if the chat has unsaved changes: if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) { var dialogParameters = new DialogParameters { { x => x.Message, T("Are you sure you want to load another chat? All unsaved changes will be lost.") }, }; - + var dialogReference = await this.DialogService.ShowAsync(T("Load Chat"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) @@ -344,15 +365,15 @@ public partial class Workspaces : MSGComponentBase return null; } - public async Task DeleteChat(string? chatPath, bool askForConfirmation = true, bool unloadChat = true) + public async Task DeleteChatAsync(string? chatPath, bool askForConfirmation = true, bool unloadChat = true) { - var chat = await this.LoadChat(chatPath, false); + var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; if (askForConfirmation) { - var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(chat.WorkspaceId); + var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId); var dialogParameters = new DialogParameters { { @@ -370,16 +391,10 @@ public partial class Workspaces : MSGComponentBase return; } - string chatDirectory; - if (chat.WorkspaceId == Guid.Empty) - chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); - else - chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - - Directory.Delete(chatDirectory, true); - await this.LoadTreeItems(); + await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false); + await this.LoadTreeItemsAsync(startPrefetch: false); - if(unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId) + if (unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId) { this.CurrentChatThread = null; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); @@ -387,9 +402,9 @@ public partial class Workspaces : MSGComponentBase } } - private async Task RenameChat(string? chatPath) + private async Task RenameChatAsync(string? chatPath) { - var chat = await this.LoadChat(chatPath, false); + var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; @@ -410,24 +425,24 @@ public partial class Workspaces : MSGComponentBase return; chat.Name = (dialogResult.Data as string)!; - if(this.CurrentChatThread?.ChatId == chat.ChatId) + if (this.CurrentChatThread?.ChatId == chat.ChatId) { this.CurrentChatThread.Name = chat.Name; await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } - await this.StoreChat(chat); - await this.LoadTreeItems(); + await WorkspaceBehaviour.StoreChatAsync(chat); + await this.LoadTreeItemsAsync(startPrefetch: false); } - - private async Task RenameWorkspace(string? workspacePath) + + private async Task RenameWorkspaceAsync(string? workspacePath) { - if(workspacePath is null) + if (workspacePath is null) return; var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); - var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId); + var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId); var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Please enter a new or edit the name for your workspace '{0}':"), workspaceName) }, @@ -447,10 +462,11 @@ public partial class Workspaces : MSGComponentBase var alteredWorkspaceName = (dialogResult.Data as string)!; var workspaceNamePath = Path.Join(workspacePath, "name"); await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8); - await this.LoadTreeItems(); + await WorkspaceBehaviour.UpdateWorkspaceNameInCacheAsync(workspaceId, alteredWorkspaceName); + await this.LoadTreeItemsAsync(startPrefetch: false); } - private async Task AddWorkspace() + private async Task AddWorkspaceAsync() { var dialogParameters = new DialogParameters { @@ -472,23 +488,23 @@ public partial class Workspaces : MSGComponentBase var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); Directory.CreateDirectory(workspacePath); + var workspaceName = (dialogResult.Data as string)!; var workspaceNamePath = Path.Join(workspacePath, "name"); - await File.WriteAllTextAsync(workspaceNamePath, (dialogResult.Data as string)!, Encoding.UTF8); + await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); + await WorkspaceBehaviour.AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName); - await this.LoadTreeItems(); + await this.LoadTreeItemsAsync(startPrefetch: false); } - private async Task DeleteWorkspace(string? workspacePath) + private async Task DeleteWorkspaceAsync(string? workspacePath) { - if(workspacePath is null) + if (workspacePath is null) return; var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); - var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId); + var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId); - // Determine how many chats are in the workspace: var chatCount = Directory.EnumerateDirectories(workspacePath).Count(); - var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace."), workspaceName, chatCount) }, @@ -500,12 +516,13 @@ public partial class Workspaces : MSGComponentBase return; Directory.Delete(workspacePath, true); - await this.LoadTreeItems(); + await WorkspaceBehaviour.RemoveWorkspaceFromCacheAsync(workspaceId); + await this.LoadTreeItemsAsync(startPrefetch: false); } - private async Task MoveChat(string? chatPath) + private async Task MoveChatAsync(string? chatPath) { - var chat = await this.LoadChat(chatPath, false); + var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; @@ -525,22 +542,9 @@ public partial class Workspaces : MSGComponentBase if (workspaceId == Guid.Empty) return; - // Delete the chat from the current workspace or the temporary storage: - if (chat.WorkspaceId == Guid.Empty) - { - // Case: The chat is stored in the temporary storage: - await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false); - } - else - { - // Case: The chat is stored in a workspace. - await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false); - } + await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false); - // Update the chat's workspace: chat.WorkspaceId = workspaceId; - - // Handle the case where the chat is the active chat: if (this.CurrentChatThread?.ChatId == chat.ChatId) { this.CurrentChatThread = chat; @@ -548,12 +552,12 @@ public partial class Workspaces : MSGComponentBase await MessageBus.INSTANCE.SendMessage(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); } - await this.StoreChat(chat); + await WorkspaceBehaviour.StoreChatAsync(chat); + await this.LoadTreeItemsAsync(startPrefetch: false); } - private async Task AddChat(string workspacePath) + private async Task AddChatAsync(string workspacePath) { - // Check if the chat has unsaved changes: if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) { var dialogParameters = new DialogParameters @@ -579,9 +583,9 @@ public partial class Workspaces : MSGComponentBase var chatPath = Path.Join(workspacePath, chat.ChatId.ToString()); - await this.StoreChat(chat); - await this.LoadChat(chatPath, switchToChat: true); - await this.LoadTreeItems(); + await WorkspaceBehaviour.StoreChatAsync(chat); + await this.LoadChatAsync(chatPath, switchToChat: true); + await this.LoadTreeItemsAsync(startPrefetch: false); } #region Overrides of MSGComponentBase @@ -591,11 +595,20 @@ public partial class Workspaces : MSGComponentBase switch (triggeredEvent) { case Event.PLUGINS_RELOADED: - await this.LoadTreeItems(); - await this.InvokeAsync(this.StateHasChanged); + await this.ForceRefreshFromDiskAsync(); break; } } + protected override void DisposeResources() + { + this.isDisposed = true; + this.prefetchCancellationTokenSource?.Cancel(); + this.prefetchCancellationTokenSource?.Dispose(); + this.prefetchCancellationTokenSource = null; + + base.DisposeResources(); + } + #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs index 9a096e27..ca4b625e 100644 --- a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs @@ -1,7 +1,4 @@ -using System.Text; - using AIStudio.Components; -using AIStudio.Settings; using Microsoft.AspNetCore.Components; @@ -29,25 +26,10 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase protected override async Task OnInitializedAsync() { this.selectedWorkspace = this.SelectedWorkspace; - - // Get the workspace root directory: - var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces"); - if(!Directory.Exists(workspaceDirectories)) - { - await base.OnInitializedAsync(); - return; - } - // Enumerate the workspace directories: - foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories)) - { - // Read the `name` file: - var workspaceNamePath = Path.Join(workspaceDirPath, "name"); - var workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); - - // Add the workspace to the list: - this.workspaces.Add(workspaceName, Guid.Parse(Path.GetFileName(workspaceDirPath))); - } + var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); + foreach (var workspace in snapshot.Workspaces) + this.workspaces[workspace.Name] = workspace.WorkspaceId; this.StateHasChanged(); await base.OnInitializedAsync(); diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index 1b2df035..b1b48dc3 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -48,6 +48,9 @@ + + + @@ -71,6 +74,9 @@ + + + @@ -137,6 +143,9 @@ + + + diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 420828c6..9adef5bb 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -98,6 +98,14 @@ public partial class Chat : MSGComponentBase await this.DialogService.ShowAsync(T("Open Workspaces Configuration"), dialogParameters, DialogOptions.FULLSCREEN); } + private async Task RefreshWorkspaces() + { + if (this.workspaces is null) + return; + + await this.workspaces.ForceRefreshFromDiskAsync(); + } + #region Overrides of MSGComponentBase protected override void DisposeResources() 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 c518d439..08d78e5a 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 @@ -2559,8 +2559,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben" --- Unnamed workspace -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unbenannter Arbeitsbereich" +-- Loading chats... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Chats werden geladen..." -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Löschen" @@ -2616,9 +2616,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Bitte geben S -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein." --- Unnamed chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unbenannter Chat" - -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen" @@ -4983,6 +4980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Lerne jeden Tag ei -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Lokalisierung" +-- Reload your workspaces +UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Arbeitsbereiche neu laden" + -- Hide your workspaces UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Arbeitsbereiche ausblenden" @@ -6521,3 +6521,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannt -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen" + +-- Unnamed chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat" 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 a4fdfd5c..5ab2e446 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 @@ -2559,8 +2559,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" --- Unnamed workspace -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unnamed workspace" +-- Loading chats... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..." -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete" @@ -2616,9 +2616,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." --- Unnamed chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unnamed chat" - -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -4983,6 +4980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one co -- Localization UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" +-- Reload your workspaces +UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Reload your workspaces" + -- Hide your workspaces UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces" @@ -6521,3 +6521,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" + +-- Unnamed chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" diff --git a/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs b/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs index 61a6e4c8..90203b2b 100644 --- a/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs +++ b/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs @@ -67,6 +67,7 @@ public sealed class TemporaryChatService(ILogger logger, S { logger.LogInformation($"Deleting temporary chat storage directory '{tempChatDirPath}' due to maintenance policy."); Directory.Delete(tempChatDirPath, true); + WorkspaceBehaviour.InvalidateWorkspaceTreeCache(); } } diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 253b4431..c03fccc8 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -12,21 +12,73 @@ namespace AIStudio.Tools; public static class WorkspaceBehaviour { + private sealed class WorkspaceChatCacheEntry + { + public Guid WorkspaceId { get; init; } + + public Guid ChatId { get; init; } + + public string ChatPath { get; init; } = string.Empty; + + public string ChatName { get; set; } = string.Empty; + + public DateTimeOffset LastEditTime { get; set; } + + public bool IsTemporary { get; init; } + } + + private sealed class WorkspaceCacheEntry + { + public Guid WorkspaceId { get; init; } + + public string WorkspacePath { get; init; } = string.Empty; + + public string WorkspaceName { get; set; } = string.Empty; + + public bool ChatsLoaded { get; set; } + + public List Chats { get; set; } = []; + } + + private sealed class WorkspaceTreeCacheState + { + public Dictionary Workspaces { get; } = []; + + public List WorkspaceOrder { get; } = []; + + public List TemporaryChats { get; set; } = []; + + public bool IsShellLoaded { get; set; } + } + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(WorkspaceBehaviour)); + private static readonly ConcurrentDictionary CHAT_STORAGE_SEMAPHORES = new(); + private static readonly SemaphoreSlim WORKSPACE_TREE_CACHE_SEMAPHORE = new(1, 1); + private static readonly WorkspaceTreeCacheState WORKSPACE_TREE_CACHE = new(); + + private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6); + private static volatile bool WORKSPACE_TREE_CACHE_INVALIDATED = true; private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour)); - /// - /// Semaphores for synchronizing chat storage operations per chat. - /// This prevents race conditions when multiple threads try to write - /// the same chat file simultaneously. - /// - private static readonly ConcurrentDictionary CHAT_STORAGE_SEMAPHORES = new(); + public static readonly JsonSerializerOptions JSON_OPTIONS = new() + { + WriteIndented = true, + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), + } + }; - /// - /// Timeout for acquiring the chat storage semaphore. - /// - private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6); + private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45); + + private static string WorkspaceRootDirectory => Path.Join(SettingsManager.DataDirectory, "workspaces"); + + private static string TemporaryChatsRootDirectory => Path.Join(SettingsManager.DataDirectory, "tempChats"); private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) { @@ -34,13 +86,6 @@ public static class WorkspaceBehaviour return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); } - /// - /// Tries to acquire the chat storage semaphore within the configured timeout. - /// - /// The workspace ID. - /// The chat ID. - /// The name of the calling method for logging purposes. - /// A tuple containing whether the semaphore was acquired and the semaphore instance. private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName) { var semaphore = GetChatSemaphore(workspaceId, chatId); @@ -56,18 +101,357 @@ public static class WorkspaceBehaviour return (acquired, semaphore); } - public static readonly JsonSerializerOptions JSON_OPTIONS = new() + private static WorkspaceTreeChat ToPublicChat(WorkspaceChatCacheEntry chat) => new(chat.WorkspaceId, chat.ChatId, chat.ChatPath, chat.ChatName, chat.LastEditTime, chat.IsTemporary); + + private static WorkspaceTreeWorkspace ToPublicWorkspace(WorkspaceCacheEntry workspace) => new(workspace.WorkspaceId, + workspace.WorkspacePath, + workspace.WorkspaceName, + workspace.ChatsLoaded, + workspace.Chats.Select(ToPublicChat).ToList()); + + private static async Task ReadNameOrDefaultAsync(string nameFilePath, string fallbackName) { - WriteIndented = true, - AllowTrailingCommas = true, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - Converters = + try { - new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), + if (!File.Exists(nameFilePath)) + return fallbackName; + + var name = await File.ReadAllTextAsync(nameFilePath, Encoding.UTF8); + return string.IsNullOrWhiteSpace(name) ? fallbackName : name; } - }; + catch + { + return fallbackName; + } + } + + private static async Task> ReadWorkspaceChatsCoreAsync(Guid workspaceId, string workspacePath) + { + var chats = new List(); + if (!Directory.Exists(workspacePath)) + return chats; + + foreach (var chatPath in Directory.EnumerateDirectories(workspacePath)) + { + if (!Guid.TryParse(Path.GetFileName(chatPath), out var chatId)) + continue; + + var chatName = await ReadNameOrDefaultAsync(Path.Join(chatPath, "name"), TB("Unnamed chat")); + var chatThreadPath = Path.Join(chatPath, "thread.json"); + var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue; + chats.Add(new WorkspaceChatCacheEntry + { + WorkspaceId = workspaceId, + ChatId = chatId, + ChatPath = chatPath, + ChatName = chatName, + LastEditTime = lastEditTime, + IsTemporary = false, + }); + } + + return chats.OrderByDescending(x => x.LastEditTime).ToList(); + } + + private static async Task> ReadTemporaryChatsCoreAsync() + { + var chats = new List(); + Directory.CreateDirectory(TemporaryChatsRootDirectory); + + foreach (var tempChatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory)) + { + if (!Guid.TryParse(Path.GetFileName(tempChatPath), out var chatId)) + continue; + + var chatName = await ReadNameOrDefaultAsync(Path.Join(tempChatPath, "name"), TB("Unnamed chat")); + var chatThreadPath = Path.Join(tempChatPath, "thread.json"); + var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue; + chats.Add(new WorkspaceChatCacheEntry + { + WorkspaceId = Guid.Empty, + ChatId = chatId, + ChatPath = tempChatPath, + ChatName = chatName, + LastEditTime = lastEditTime, + IsTemporary = true, + }); + } + + return chats.OrderByDescending(x => x.LastEditTime).ToList(); + } + + private static async Task EnsureTreeShellLoadedCoreAsync() + { + if (!WORKSPACE_TREE_CACHE_INVALIDATED && WORKSPACE_TREE_CACHE.IsShellLoaded) + return; + + WORKSPACE_TREE_CACHE.Workspaces.Clear(); + WORKSPACE_TREE_CACHE.WorkspaceOrder.Clear(); + + Directory.CreateDirectory(WorkspaceRootDirectory); + foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory)) + { + if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId)) + continue; + + var workspaceName = await ReadNameOrDefaultAsync(Path.Join(workspacePath, "name"), TB("Unnamed workspace")); + WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry + { + WorkspaceId = workspaceId, + WorkspacePath = workspacePath, + WorkspaceName = workspaceName, + ChatsLoaded = false, + Chats = [], + }; + + WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId); + } + + WORKSPACE_TREE_CACHE.TemporaryChats = await ReadTemporaryChatsCoreAsync(); + WORKSPACE_TREE_CACHE.IsShellLoaded = true; + WORKSPACE_TREE_CACHE_INVALIDATED = false; + } + + private static void UpsertChatInCache(List chats, WorkspaceChatCacheEntry chat) + { + var existingIndex = chats.FindIndex(existing => existing.ChatId == chat.ChatId); + if (existingIndex >= 0) + chats[existingIndex] = chat; + else + chats.Add(chat); + + chats.Sort((a, b) => b.LastEditTime.CompareTo(a.LastEditTime)); + } + + private static void DeleteChatFromCache(List chats, Guid chatId) + { + var existingIndex = chats.FindIndex(existing => existing.ChatId == chatId); + if (existingIndex >= 0) + chats.RemoveAt(existingIndex); + } + + private static async Task UpdateCacheAfterChatStored(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED) + return; + + var chatCacheEntry = new WorkspaceChatCacheEntry + { + WorkspaceId = workspaceId, + ChatId = chatId, + ChatPath = chatDirectory, + ChatName = string.IsNullOrWhiteSpace(chatName) ? TB("Unnamed chat") : chatName, + LastEditTime = lastEditTime, + IsTemporary = workspaceId == Guid.Empty, + }; + + if (workspaceId == Guid.Empty) + { + UpsertChatInCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatCacheEntry); + return; + } + + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded) + UpsertChatInCache(workspace.Chats, chatCacheEntry); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + private static async Task UpdateCacheAfterChatDeleted(Guid workspaceId, Guid chatId) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED) + return; + + if (workspaceId == Guid.Empty) + { + DeleteChatFromCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatId); + return; + } + + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded) + DeleteChatFromCache(workspace.Chats, chatId); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static void InvalidateWorkspaceTreeCache() + { + WORKSPACE_TREE_CACHE_INVALIDATED = true; + } + + public static async Task ForceReloadWorkspaceTreeAsync() + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + WORKSPACE_TREE_CACHE_INVALIDATED = false; + WORKSPACE_TREE_CACHE.IsShellLoaded = false; + await EnsureTreeShellLoadedCoreAsync(); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task GetOrLoadWorkspaceTreeShellAsync() + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + var workspaces = WORKSPACE_TREE_CACHE.WorkspaceOrder + .Where(workspaceId => WORKSPACE_TREE_CACHE.Workspaces.ContainsKey(workspaceId)) + .Select(workspaceId => ToPublicWorkspace(WORKSPACE_TREE_CACHE.Workspaces[workspaceId])) + .ToList(); + var temporaryChats = WORKSPACE_TREE_CACHE.TemporaryChats.Select(ToPublicChat).ToList(); + return new WorkspaceTreeCacheSnapshot(workspaces, temporaryChats); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task> GetWorkspaceChatsAsync(Guid workspaceId, bool forceRefresh = false) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + return []; + + if (forceRefresh || !workspace.ChatsLoaded) + { + workspace.Chats = await ReadWorkspaceChatsCoreAsync(workspaceId, workspace.WorkspacePath); + workspace.ChatsLoaded = true; + } + + return workspace.Chats.Select(ToPublicChat).ToList(); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task TryPrefetchRemainingChatsAsync(Func? onWorkspaceUpdated = null, CancellationToken token = default) + { + while (true) + { + token.ThrowIfCancellationRequested(); + Guid? workspaceToPrefetch = null; + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(token); + try + { + await EnsureTreeShellLoadedCoreAsync(); + foreach (var workspaceId in WORKSPACE_TREE_CACHE.WorkspaceOrder) + { + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && !workspace.ChatsLoaded) + { + workspaceToPrefetch = workspaceId; + break; + } + } + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + + if (workspaceToPrefetch is null) + return; + + await GetWorkspaceChatsAsync(workspaceToPrefetch.Value); + if (onWorkspaceUpdated is not null) + { + try + { + await onWorkspaceUpdated(workspaceToPrefetch.Value); + } + catch (Exception ex) + { + LOG.LogWarning(ex, "Failed to process callback after prefetching workspace '{WorkspaceId}'.", workspaceToPrefetch.Value); + } + } + + await Task.Delay(PREFETCH_DELAY_DURATION, token); + } + } + + public static async Task AddWorkspaceToCacheAsync(Guid workspaceId, string workspacePath, string workspaceName) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + { + workspace.WorkspaceName = workspaceName; + return; + } + + WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry + { + WorkspaceId = workspaceId, + WorkspacePath = workspacePath, + WorkspaceName = workspaceName, + Chats = [], + ChatsLoaded = false, + }; + WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task UpdateWorkspaceNameInCacheAsync(Guid workspaceId, string workspaceName) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + workspace.WorkspaceName = workspaceName; + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId) + { + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED) + return; + + WORKSPACE_TREE_CACHE.Workspaces.Remove(workspaceId); + WORKSPACE_TREE_CACHE.WorkspaceOrder.Remove(workspaceId); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } public static bool IsChatExisting(LoadChat loadChat) { @@ -78,31 +462,28 @@ public static class WorkspaceBehaviour return Directory.Exists(chatPath); } - public static async Task StoreChat(ChatThread chat) + public static async Task StoreChatAsync(ChatThread chat) { - // Try to acquire the semaphore for this specific chat to prevent concurrent writes to the same file: - var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChat)); + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync)); if (!acquired) return; try { - string chatDirectory; - if (chat.WorkspaceId == Guid.Empty) - chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); - else - chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + var chatDirectory = chat.WorkspaceId == Guid.Empty + ? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()) + : Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - // Ensure the directory exists: Directory.CreateDirectory(chatDirectory); - // Save the chat name: var chatNamePath = Path.Join(chatDirectory, "name"); await File.WriteAllTextAsync(chatNamePath, chat.Name); - // Save the thread as thread.json: var chatPath = Path.Join(chatDirectory, "thread.json"); await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + + var lastEditTime = File.GetLastWriteTimeUtc(chatPath); + await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime); } finally { @@ -110,10 +491,9 @@ public static class WorkspaceBehaviour } } - public static async Task LoadChat(LoadChat loadChat) + public static async Task LoadChatAsync(LoadChat loadChat) { - // Try to acquire the semaphore for this specific chat to prevent concurrent read/writes to the same file: - var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChat)); + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChatAsync)); if (!acquired) return null; @@ -123,12 +503,11 @@ public static class WorkspaceBehaviour ? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString()) : Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString()); - if(!Directory.Exists(chatPath)) + if (!Directory.Exists(chatPath)) return null; var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); - var chat = JsonSerializer.Deserialize(chatData, JSON_OPTIONS); - return chat; + return JsonSerializer.Deserialize(chatData, JSON_OPTIONS); } catch (Exception) { @@ -140,51 +519,68 @@ public static class WorkspaceBehaviour } } - public static async Task LoadWorkspaceName(Guid workspaceId) + public static async Task LoadWorkspaceNameAsync(Guid workspaceId) { - if(workspaceId == Guid.Empty) + if (workspaceId == Guid.Empty) return string.Empty; - - var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); - var workspaceNamePath = Path.Join(workspacePath, "name"); - + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); try { - // If the name file does not exist or is empty, self-heal with a default name. - if (!File.Exists(workspaceNamePath)) + await EnsureTreeShellLoadedCoreAsync(); + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var cachedWorkspace) && !string.IsNullOrWhiteSpace(cachedWorkspace.WorkspaceName)) + return cachedWorkspace.WorkspaceName; + + // Not in cache — read from disk and update cache in the same semaphore scope + // to avoid a second semaphore acquisition via UpdateWorkspaceNameInCacheAsync: + var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString()); + var workspaceNamePath = Path.Join(workspacePath, "name"); + string workspaceName; + + try { - var defaultName = TB("Unnamed workspace"); - Directory.CreateDirectory(workspacePath); - await File.WriteAllTextAsync(workspaceNamePath, defaultName, Encoding.UTF8); - return defaultName; + if (!File.Exists(workspaceNamePath)) + { + workspaceName = TB("Unnamed workspace"); + Directory.CreateDirectory(workspacePath); + await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); + } + else + { + workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); + if (string.IsNullOrWhiteSpace(workspaceName)) + { + workspaceName = TB("Unnamed workspace"); + await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); + } + } } - - var name = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); - if (string.IsNullOrWhiteSpace(name)) + catch { - var defaultName = TB("Unnamed workspace"); - await File.WriteAllTextAsync(workspaceNamePath, defaultName, Encoding.UTF8); - return defaultName; + workspaceName = TB("Unnamed workspace"); } - - return name; + + // Update the cache directly (we already hold the semaphore): + if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + workspace.WorkspaceName = workspaceName; + + return workspaceName; } - catch + finally { - // On any error, return a localized default without throwing. - return TB("Unnamed workspace"); + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); } } - public static async Task DeleteChat(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true) + public static async Task DeleteChatAsync(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true) { - var chat = await LoadChat(new(workspaceId, chatId)); + var chat = await LoadChatAsync(new(workspaceId, chatId)); if (chat is null) return; if (askForConfirmation) { - var workspaceName = await LoadWorkspaceName(chat.WorkspaceId); + var workspaceName = await LoadWorkspaceNameAsync(chat.WorkspaceId); var dialogParameters = new DialogParameters { { @@ -202,20 +598,20 @@ public static class WorkspaceBehaviour return; } - string chatDirectory; - if (chat.WorkspaceId == Guid.Empty) - chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); - else - chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + var chatDirectory = chat.WorkspaceId == Guid.Empty + ? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()) + : Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - // Try to acquire the semaphore to prevent deleting while another thread is writing: - var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChat)); + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChatAsync)); if (!acquired) return; try { - Directory.Delete(chatDirectory, true); + if (Directory.Exists(chatDirectory)) + Directory.Delete(chatDirectory, true); + + await UpdateCacheAfterChatDeleted(workspaceId, chatId); } finally { @@ -225,16 +621,14 @@ public static class WorkspaceBehaviour private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) { - var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); + var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString()); var workspaceNamePath = Path.Join(workspacePath, "name"); - if(!Path.Exists(workspacePath)) + if (!Path.Exists(workspacePath)) Directory.CreateDirectory(workspacePath); try { - // When the name file is missing or empty, write it (self-heal). - // Otherwise, keep the existing name: if (!File.Exists(workspaceNamePath)) { await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); @@ -250,9 +644,11 @@ public static class WorkspaceBehaviour { // Ignore IO issues to avoid interrupting background initialization. } + + await AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName); } - + public static async Task EnsureBiasWorkspace() => await EnsureWorkspace(KnownWorkspaces.BIAS_WORKSPACE_ID, "Bias of the Day"); - + public static async Task EnsureERIServerWorkspace() => await EnsureWorkspace(KnownWorkspaces.ERI_SERVER_WORKSPACE_ID, "ERI Servers"); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/WorkspaceTreeCacheSnapshot.cs b/app/MindWork AI Studio/Tools/WorkspaceTreeCacheSnapshot.cs new file mode 100644 index 00000000..8e36f5d5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceTreeCacheSnapshot.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools; + +public readonly record struct WorkspaceTreeCacheSnapshot(IReadOnlyList Workspaces, IReadOnlyList TemporaryChats); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WorkspaceTreeChat.cs b/app/MindWork AI Studio/Tools/WorkspaceTreeChat.cs new file mode 100644 index 00000000..3976667d --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceTreeChat.cs @@ -0,0 +1,4 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +namespace AIStudio.Tools; + +public readonly record struct WorkspaceTreeChat(Guid WorkspaceId, Guid ChatId, string ChatPath, string Name, DateTimeOffset LastEditTime, bool IsTemporary); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/WorkspaceTreeWorkspace.cs b/app/MindWork AI Studio/Tools/WorkspaceTreeWorkspace.cs new file mode 100644 index 00000000..d8eed9bb --- /dev/null +++ b/app/MindWork AI Studio/Tools/WorkspaceTreeWorkspace.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools; + +public readonly record struct WorkspaceTreeWorkspace(Guid WorkspaceId, string WorkspacePath, string Name, bool ChatsLoaded, IReadOnlyList Chats); \ 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 f5bd763b..18ba3eab 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -1,6 +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 workspace loading experience: when opening the chat for the first time, your workspaces now appear faster and load step by step in the background, with placeholder rows so the app feels responsive right away. - 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.