From 894c509f4d1ea80322055799a78f3a76cc619d1a Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:20:01 +0200 Subject: [PATCH 1/9] Fix document analysis provider selection freezes --- .../Assistants/AssistantBase.razor.cs | 2 +- .../DocumentAnalysisAssistant.razor | 6 +-- .../DocumentAnalysisAssistant.razor.cs | 37 +++++++++---- .../Assistants/I18N/allTexts.lua | 3 ++ .../Components/ProviderSelection.razor | 13 ++++- .../Settings/SettingsManager.cs | 52 ++++++++++++------- .../wwwroot/changelog/v26.9.1.md | 1 + 7 files changed, 80 insertions(+), 34 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index a3939cf47..4d6fde091 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -184,7 +184,7 @@ protected override async Task OnInitializedAsync() this.formChangeTimer.Elapsed += (_, _) => { this.formChangeTimer.Stop(); - this.OnFormChange().Observe($"{nameof(AssistantBase)}: handling a form change"); + this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase)}: handling a form change"); }; this.MightPreselectValues(); diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index 997386482..b43a5cb7e 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -104,13 +104,13 @@ else @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.") - + - + - + diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index e1067b43c..b9adc6685 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -180,6 +180,7 @@ protected override ChatThread ConvertToChatThread protected override void ResetForm() { this.loadedDocumentPaths.Clear(); + this.policyNameWasEdited = false; if (!this.MightPreselectValues()) { this.policyName = string.Empty; @@ -220,6 +221,7 @@ protected override bool MightPreselectValues() this.policyAllowedToolIds = [..this.selectedPolicy.AllowedToolIds]; this.policyPreselectedProviderId = this.selectedPolicy.PreselectedProvider; this.policyPreselectedProfile = ProfilePreselection.FromStoredValue(this.selectedPolicy.PreselectedProfile); + this.policyNameWasEdited = false; return true; } @@ -259,6 +261,7 @@ private async Task AutoSave(bool force = false) return; // The preselected profile is always user-adjustable, even for protected policies and enterprise configurations: + var hasChanges = this.selectedPolicy.PreselectedProfile != this.policyPreselectedProfile; this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile; // Enterprise configurations cannot be modified at all: @@ -268,6 +271,17 @@ private async Task AutoSave(bool force = false) var canEditProtectedFields = force || (!this.selectedPolicy.IsProtected && !this.policyIsProtected); if (canEditProtectedFields) { + hasChanges = hasChanges + || this.policyNameWasEdited + || this.selectedPolicy.PreselectedProvider != this.policyPreselectedProviderId + || this.selectedPolicy.PolicyDescription != this.policyDescription + || this.selectedPolicy.IsProtected != this.policyIsProtected + || this.selectedPolicy.HidePolicyDefinition != this.policyHidePolicyDefinition + || this.selectedPolicy.AnalysisRules != this.policyAnalysisRules + || this.selectedPolicy.OutputRules != this.policyOutputRules + || this.selectedPolicy.MinimumProviderConfidence != this.policyMinimumProviderConfidence + || !this.selectedPolicy.AllowedToolIds.SetEquals(this.policyAllowedToolIds); + this.selectedPolicy.PreselectedProvider = this.policyPreselectedProviderId; this.selectedPolicy.PolicyName = this.policyName; this.selectedPolicy.PolicyDescription = this.policyDescription; @@ -279,7 +293,11 @@ private async Task AutoSave(bool force = false) this.selectedPolicy.AllowedToolIds = [..this.policyAllowedToolIds]; } + if (!hasChanges) + return; + await this.SettingsManager.StoreSettings(); + this.policyNameWasEdited = false; } private DataDocumentAnalysisPolicy? selectedPolicy; @@ -298,6 +316,7 @@ private async Task AutoSave(bool force = false) /// private bool documentSelectionExpanded; private string policyName = string.Empty; + private bool policyNameWasEdited; private string policyDescription = string.Empty; private string policyAnalysisRules = string.Empty; private string policyOutputRules = string.Empty; @@ -477,6 +496,7 @@ private void PolicyNameWasChanged() return; this.selectedPolicy.PolicyName = this.policyName; + this.policyNameWasEdited = true; } private async Task PolicyProtectionWasChanged(bool state) @@ -488,7 +508,6 @@ private async Task PolicyProtectionWasChanged(bool state) return; this.policyIsProtected = state; - this.selectedPolicy.IsProtected = state; this.policyDefinitionExpanded = !state; this.documentSelectionExpanded = state; await this.AutoSave(true); @@ -503,7 +522,6 @@ private async Task PolicyHidePolicyDefinitionWasChanged(bool state) return; this.policyHidePolicyDefinition = state; - this.selectedPolicy.HidePolicyDefinition = state; await this.AutoSave(true); } @@ -580,17 +598,19 @@ private Profile ResolveProfileSelection() /// /// Takes over the tools this policy permits. /// - private async Task PolicyAllowedToolsWasChangedAsync(HashSet allowedToolIds) + private void PolicyAllowedToolsWasChanged(HashSet allowedToolIds) { this.policyAllowedToolIds = allowedToolIds; - await this.AutoSave(); + if (this.selectedPolicy is not null) + this.selectedPolicy.AllowedToolIds = [..allowedToolIds]; } - private async Task PolicyMinimumConfidenceWasChangedAsync(ConfidenceLevel level) + private void PolicyMinimumConfidenceWasChanged(ConfidenceLevel level) { this.policyMinimumProviderConfidence = level; - await this.AutoSave(); - + if (this.selectedPolicy is not null) + this.selectedPolicy.MinimumProviderConfidence = level; + this.ApplyPolicyPreselection(); } @@ -605,14 +625,13 @@ private void PolicyPreselectedProviderWasChanged(string providerId) this.ApplyPolicyPreselection(); } - private async Task PolicyPreselectedProfileWasChangedAsync(ProfilePreselection selection) + private void PolicyPreselectedProfileWasChanged(ProfilePreselection selection) { this.policyPreselectedProfile = selection; if (this.selectedPolicy is not null) this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile; this.CurrentProfile = this.ResolveProfileSelection(); - await this.AutoSave(); } #region Overrides of MSGComponentBase diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 2a19b77f8..d5b3ab5ea 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -4213,6 +4213,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" +-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings." + -- Audio input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible" diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor b/app/MindWork AI Studio/Components/ProviderSelection.razor index e4ca191a0..253bce1c6 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor @@ -1,7 +1,10 @@ @using AIStudio.Settings @inherits MSGComponentBase +@{ + var availableProviderItems = this.GetAvailableProviderSelectionItems().ToList(); +} - @foreach (var providerItem in this.GetAvailableProviderSelectionItems()) + @foreach (var providerItem in availableProviderItems) { @@ -20,4 +23,10 @@ } - \ No newline at end of file + +@if (availableProviderItems.Count is 0) +{ + + @T("No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.") + +} diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index 3f613f281..aeb6f31be 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -33,6 +33,7 @@ public sealed class SettingsManager private readonly ILogger logger; private readonly RustService rustService; + private readonly SemaphoreSlim settingsWriteSemaphore = new(1, 1); /// /// The settings manager. @@ -294,41 +295,56 @@ private void PrepareLoadedSettings(Data settingsData) /// public async Task StoreSettings() { - if(!this.IsSetUp) + await this.settingsWriteSemaphore.WaitAsync(); + try { - this.logger.LogWarning("Cannot store settings, because the configuration is not set up yet."); - return; - } + if(!this.IsSetUp) + { + this.logger.LogWarning("Cannot store settings, because the configuration is not set up yet."); + return; + } + + if(this.SettingsWriteBlocked) + { + this.logger.LogWarning($"Cannot store settings, because settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'."); + return; + } - if(this.SettingsWriteBlocked) + var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS); + var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); + await this.StoreSettingsSnapshot(settingsJson, settingsPath); + await this.StoreCurrentVersionBackup(this.ConfigurationData.Version, settingsJson); + } + finally { - this.logger.LogWarning($"Cannot store settings, because settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'."); - return; + this.settingsWriteSemaphore.Release(); } - - var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); - await this.StoreSettingsSnapshot(this.ConfigurationData, settingsPath); - await this.StoreCurrentVersionBackup(this.ConfigurationData); } private static string GetBackupSettingsFilename(Version version) => $"settings.{version.ToString().ToLowerInvariant()}.json"; private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version)); - private async Task StoreCurrentVersionBackup(Data settingsData) + private Task StoreCurrentVersionBackup(Data settingsData) => + this.StoreCurrentVersionBackup(settingsData.Version, JsonSerializer.Serialize(settingsData, JSON_OPTIONS)); + + private async Task StoreCurrentVersionBackup(Version settingsVersion, string settingsJson) { - if(settingsData.Version != CURRENT_SETTINGS_VERSION) + if(settingsVersion != CURRENT_SETTINGS_VERSION) { - this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsData.Version}' is not the current version '{CURRENT_SETTINGS_VERSION}'."); + this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsVersion}' is not the current version '{CURRENT_SETTINGS_VERSION}'."); return; } var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION); - await this.StoreSettingsSnapshot(settingsData, backupSettingsPath); + await this.StoreSettingsSnapshot(settingsJson, backupSettingsPath); this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'."); } - private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath) + private Task StoreSettingsSnapshot(Data settingsData, string settingsPath) => + this.StoreSettingsSnapshot(JsonSerializer.Serialize(settingsData, JSON_OPTIONS), settingsPath); + + private async Task StoreSettingsSnapshot(string settingsJson, string settingsPath) { if(!Directory.Exists(ConfigDirectory)) { @@ -336,8 +352,6 @@ private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath) Directory.CreateDirectory(ConfigDirectory!); } - var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS); - // // We write the new settings next to the previous ones and replace them afterwards, so that // no crash can leave a half-written settings file behind. The temporary file has to live in @@ -349,7 +363,7 @@ private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath) try { await File.WriteAllTextAsync(tempFile, settingsJson); - File.Move(tempFile, settingsPath, true); + await Task.Run(() => File.Move(tempFile, settingsPath, true)); } catch { diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 85a6cdc2f..758568a93 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -26,6 +26,7 @@ - Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files. - Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet. - Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had. +- Fixed the Document Analysis assistant becoming unresponsive when choosing an LLM provider. - Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability. - Fixed a model resold under a plain name not getting the abilities it really has. - Fixed image and video generation models showing up among the chat models. From a1fe22ac66587969bbe3117d5c1e2f86ac2e63d1 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:46:42 +0200 Subject: [PATCH 2/9] Fix lost policy renames and unguarded settings reads --- .../DocumentAnalysisAssistant.razor.cs | 105 +++++++++++------- .../Components/ProviderSelection.razor | 4 +- .../Components/ProviderSelection.razor.cs | 22 ++++ .../Settings/SettingsManager.cs | 29 ++++- 4 files changed, 115 insertions(+), 45 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index b9adc6685..f2a7ad96f 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -180,7 +180,6 @@ protected override ChatThread ConvertToChatThread protected override void ResetForm() { this.loadedDocumentPaths.Clear(); - this.policyNameWasEdited = false; if (!this.MightPreselectValues()) { this.policyName = string.Empty; @@ -221,7 +220,6 @@ protected override bool MightPreselectValues() this.policyAllowedToolIds = [..this.selectedPolicy.AllowedToolIds]; this.policyPreselectedProviderId = this.selectedPolicy.PreselectedProvider; this.policyPreselectedProfile = ProfilePreselection.FromStoredValue(this.selectedPolicy.PreselectedProfile); - this.policyNameWasEdited = false; return true; } @@ -257,47 +255,64 @@ protected override async Task OnInitializedAsync() private async Task AutoSave(bool force = false) { - if(this.selectedPolicy is null) - return; - - // The preselected profile is always user-adjustable, even for protected policies and enterprise configurations: - var hasChanges = this.selectedPolicy.PreselectedProfile != this.policyPreselectedProfile; - this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile; - - // Enterprise configurations cannot be modified at all: - if(this.selectedPolicy.IsEnterpriseConfiguration) - return; - - var canEditProtectedFields = force || (!this.selectedPolicy.IsProtected && !this.policyIsProtected); - if (canEditProtectedFields) - { - hasChanges = hasChanges - || this.policyNameWasEdited - || this.selectedPolicy.PreselectedProvider != this.policyPreselectedProviderId - || this.selectedPolicy.PolicyDescription != this.policyDescription - || this.selectedPolicy.IsProtected != this.policyIsProtected - || this.selectedPolicy.HidePolicyDefinition != this.policyHidePolicyDefinition - || this.selectedPolicy.AnalysisRules != this.policyAnalysisRules - || this.selectedPolicy.OutputRules != this.policyOutputRules - || this.selectedPolicy.MinimumProviderConfidence != this.policyMinimumProviderConfidence - || !this.selectedPolicy.AllowedToolIds.SetEquals(this.policyAllowedToolIds); - - this.selectedPolicy.PreselectedProvider = this.policyPreselectedProviderId; - this.selectedPolicy.PolicyName = this.policyName; - this.selectedPolicy.PolicyDescription = this.policyDescription; - this.selectedPolicy.IsProtected = this.policyIsProtected; - this.selectedPolicy.HidePolicyDefinition = this.policyHidePolicyDefinition; - this.selectedPolicy.AnalysisRules = this.policyAnalysisRules; - this.selectedPolicy.OutputRules = this.policyOutputRules; - this.selectedPolicy.MinimumProviderConfidence = this.policyMinimumProviderConfidence; - this.selectedPolicy.AllowedToolIds = [..this.policyAllowedToolIds]; - } + // + // A pending name store is a property of the settings, not of the selected policy: the name + // is written into its policy the very moment it is typed, so what is still outstanding is + // the store itself. It therefore outlives a form reset and a switch to another policy, and + // only a completed store clears it. + // + var hasChanges = this.policyNameStorePending; + if(this.selectedPolicy is { } policy) + hasChanges |= this.ApplyFormToPolicy(policy, force); if (!hasChanges) return; await this.SettingsManager.StoreSettings(); - this.policyNameWasEdited = false; + this.policyNameStorePending = false; + } + + /// + /// Takes the form values over into the given policy. + /// + /// The policy to write the form values to. + /// Whether the protected fields may be written as well. + /// True when this changed anything about the policy, false otherwise. + private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force) + { + // The preselected profile is always user-adjustable, even for protected policies and enterprise configurations: + var hasChanges = policy.PreselectedProfile != this.policyPreselectedProfile; + policy.PreselectedProfile = this.policyPreselectedProfile; + + // Enterprise configurations cannot be modified at all: + if(policy.IsEnterpriseConfiguration) + return false; + + var canEditProtectedFields = force || (!policy.IsProtected && !this.policyIsProtected); + if (!canEditProtectedFields) + return hasChanges; + + hasChanges = hasChanges + || policy.PolicyName != this.policyName + || policy.PreselectedProvider != this.policyPreselectedProviderId + || policy.PolicyDescription != this.policyDescription + || policy.IsProtected != this.policyIsProtected + || policy.HidePolicyDefinition != this.policyHidePolicyDefinition + || policy.AnalysisRules != this.policyAnalysisRules + || policy.OutputRules != this.policyOutputRules + || policy.MinimumProviderConfidence != this.policyMinimumProviderConfidence + || !policy.AllowedToolIds.SetEquals(this.policyAllowedToolIds); + + policy.PreselectedProvider = this.policyPreselectedProviderId; + policy.PolicyName = this.policyName; + policy.PolicyDescription = this.policyDescription; + policy.IsProtected = this.policyIsProtected; + policy.HidePolicyDefinition = this.policyHidePolicyDefinition; + policy.AnalysisRules = this.policyAnalysisRules; + policy.OutputRules = this.policyOutputRules; + policy.MinimumProviderConfidence = this.policyMinimumProviderConfidence; + policy.AllowedToolIds = [..this.policyAllowedToolIds]; + return hasChanges; } private DataDocumentAnalysisPolicy? selectedPolicy; @@ -316,7 +331,17 @@ private async Task AutoSave(bool force = false) /// private bool documentSelectionExpanded; private string policyName = string.Empty; - private bool policyNameWasEdited; + + /// + /// Whether a typed policy name still waits to be written to the settings file. + /// + /// + /// Typing a name applies it to its policy at once, so that the policy list shows the new name + /// right away -- which leaves nothing for the auto-save to compare the form against. This flag + /// is what tells it that a store is nevertheless due. It belongs to no particular policy: the + /// name has long arrived where it belongs, only the file has not caught up yet. + /// + private bool policyNameStorePending; private string policyDescription = string.Empty; private string policyAnalysisRules = string.Empty; private string policyOutputRules = string.Empty; @@ -496,7 +521,7 @@ private void PolicyNameWasChanged() return; this.selectedPolicy.PolicyName = this.policyName; - this.policyNameWasEdited = true; + this.policyNameStorePending = true; } private async Task PolicyProtectionWasChanged(bool state) diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor b/app/MindWork AI Studio/Components/ProviderSelection.razor index 253bce1c6..f1fab87dd 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor @@ -24,9 +24,9 @@ } -@if (availableProviderItems.Count is 0) +@if (availableProviderItems.Count is 0 && this.GetEmptySelectionHint() is { } emptySelectionHint) { - @T("No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.") + @emptySelectionHint } diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index 0847111ce..b40175f67 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -53,6 +53,28 @@ private IEnumerable GetAvailableProviderSelectionItems() yield return new(provider, this.GetCapabilityIcons(provider)); } + /// + /// Says why there is nothing to choose from, or nothing at all when that is not the user's doing. + /// + /// + /// An empty list has two causes the user can act on, and they lead to different places in the + /// settings: there is no provider yet, or none of the configured ones reaches the confidence + /// this component asks for. Naming the wrong one sends the user looking in the wrong place -- + /// a first start has nobody to blame for a confidence level it never set. A missing or invalid + /// component is a third case and neither of those: it is a defect, it was logged as one, and + /// any explanation offered to the user here would be a guess. + /// + private string? GetEmptySelectionHint() + { + if (this.Component is null or Tools.Components.NONE) + return null; + + if (!this.SettingsManager.GetAllProviders().Any(x => x.UsedLLMProvider is not LLMProviders.NONE)) + return this.T("No LLM providers are configured yet. Add a provider in the app settings."); + + return this.T("No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings."); + } + private IReadOnlyList GetCapabilityIcons(AIStudio.Settings.Provider provider) { var profile = provider.GetModelProfile(); diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index aeb6f31be..7dfceb31e 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -33,7 +33,17 @@ public sealed class SettingsManager private readonly ILogger logger; private readonly RustService rustService; - private readonly SemaphoreSlim settingsWriteSemaphore = new(1, 1); + + /// + /// Lets only one operation at a time touch the settings files. + /// + /// + /// Reading takes this as well as writing does, for two reasons. A read migrates and backs up + /// what it found, so it writes the very files a store writes. And it re-evaluates whether + /// writes are blocked at all, starting out by clearing that block: a store slipping through + /// that moment would overwrite the settings the block exists to protect. + /// + private readonly SemaphoreSlim settingsFileSemaphore = new(1, 1); /// /// The settings manager. @@ -104,6 +114,19 @@ public async Task LoadSettings() /// /// A (migrated) settings snapshot, or null if it could not be read. public async Task TryReadSettingsSnapshot() + { + await this.settingsFileSemaphore.WaitAsync(); + try + { + return await this.ReadSettingsSnapshot(); + } + finally + { + this.settingsFileSemaphore.Release(); + } + } + + private async Task ReadSettingsSnapshot() { this.SettingsWriteBlockReason = SettingsWriteBlockReason.NONE; if(!this.IsSetUp) @@ -295,7 +318,7 @@ private void PrepareLoadedSettings(Data settingsData) /// public async Task StoreSettings() { - await this.settingsWriteSemaphore.WaitAsync(); + await this.settingsFileSemaphore.WaitAsync(); try { if(!this.IsSetUp) @@ -317,7 +340,7 @@ public async Task StoreSettings() } finally { - this.settingsWriteSemaphore.Release(); + this.settingsFileSemaphore.Release(); } } From a74ac0d63c1b394ba77cbc536ada3c0d8764e8be Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:03:20 +0200 Subject: [PATCH 3/9] I18n --- .../Assistants/I18N/allTexts.lua | 3 +++ .../plugin.lua | 21 +++++++++++++++++++ .../plugin.lua | 21 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index d5b3ab5ea..b15fc6d0c 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -4213,6 +4213,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" +-- No LLM providers are configured yet. Add a provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "No LLM providers are configured yet. Add a provider in the app settings." + -- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings." 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 61780dfa2..99d8b498c 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 @@ -3348,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "Das ausgewählte -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3378120620"] = "Wir konnten Modelle von '{0}' laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." +-- Your data sources could not be used. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T373499115"] = "Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt." + -- The local image file does not exist. Skipping the image. UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T255679918"] = "Die lokale Bilddatei existiert nicht. Das Bild wird übersprungen." @@ -4212,6 +4215,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Profil -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "Hier können Sie zwischen ihren Profilen wechseln." +-- No LLM providers are configured yet. Add a provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "Bisher wurden keine LLM-Anbieter konfiguriert. Bitte fügen Sie einen Anbieter in den App-Einstellungen hinzu." + +-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "Kein LLM-Anbieter erfüllt die Sicherheitsanforderungen. Bitte konfiguriere Sie einen gültigen Anbieter in den App-Einstellungen." + -- Audio input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audioeingabe möglich" @@ -10464,6 +10473,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "Das aus -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "Wir konnten Modelle von „{0}“ laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." +-- Your data sources could not be used. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T373499115"] = "Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt." + -- Software Development UI_TEXT_CONTENT["AISTUDIO::TOOLS::ASSISTANTCATEGORYEXTENSIONS::T1025369409"] = "Softwareentwicklung" @@ -11568,15 +11580,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T335338363 -- Standard augmentation process UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T1072508429"] = "Standardmäßiger Erweiterungsprozess" +-- No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T2710880477"] = "Kein Anbieter ist vertrauenswürdig genug, um zu prüfen, welche Abschnitte zu deiner Frage passen. Diese Antwort verwendet alle gefundenen Abschnitte." + -- This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T3240406069"] = "Dies ist der Standard-Erweiterungsprozess, bei dem alle abgerufenen Kontexte verwendet werden, um den Chatverlauf zu ergänzen." +-- The check of which passages fit your question failed. This answer uses all passages that were found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T392269104"] = "Die Prüfung, welche Textstellen zu Ihrer Frage passen, ist fehlgeschlagen. Diese Antwort verwendet alle gefundenen Textstellen." + -- Automatic AI data source selection with heuristik source reduction UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T2339257645"] = "Automatische Auswahl der Datenquellen mittels KI und mit heuristischer Datenquellen-Reduktion" -- Automatically selects the appropriate data sources based on the last prompt. Applies a heuristic reduction at the end to reduce the number of data sources. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T648937779"] = "Wählt automatisch die passenden Datenquellen basierend auf der letzten Eingabe aus. Wendet am Ende eine heuristische Reduzierung an, um die Anzahl der Datenquellen zu verringern." +-- None of your selected data sources is available for the chosen provider. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T1696726639"] = "Keine der ausgewählten Datenquellen ist für den gewählten Anbieter verfügbar. Diese Antwort wurde ohne sie erstellt." + -- This RAG process filters data sources, automatically selects appropriate sources, optionally allows manual source selection, retrieves data, and automatically validates the retrieval context. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3047786484"] = "Dieser RAG-Prozess filtert Datenquellen, wählt automatisch passende Quellen aus, ermöglicht optional die manuelle Auswahl von Quellen, ruft Daten ab und überprüft den Abrufkontext automatisch." 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 bb7a44ffd..7441b0d75 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 @@ -3348,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected mode -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models." +-- Your data sources could not be used. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T373499115"] = "Your data sources could not be used. This answer was created without them." + -- The local image file does not exist. Skipping the image. UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T255679918"] = "The local image file does not exist. Skipping the image." @@ -4212,6 +4215,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" +-- No LLM providers are configured yet. Add a provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "No LLM providers are configured yet. Add a provider in the app settings." + +-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings." + -- Audio input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible" @@ -10464,6 +10473,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "The sel -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models." +-- Your data sources could not be used. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T373499115"] = "Your data sources could not be used. This answer was created without them." + -- Software Development UI_TEXT_CONTENT["AISTUDIO::TOOLS::ASSISTANTCATEGORYEXTENSIONS::T1025369409"] = "Software Development" @@ -11568,15 +11580,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T335338363 -- Standard augmentation process UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T1072508429"] = "Standard augmentation process" +-- No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T2710880477"] = "No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found." + -- This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T3240406069"] = "This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread." +-- The check of which passages fit your question failed. This answer uses all passages that were found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T392269104"] = "The check of which passages fit your question failed. This answer uses all passages that were found." + -- Automatic AI data source selection with heuristik source reduction UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T2339257645"] = "Automatic AI data source selection with heuristik source reduction" -- Automatically selects the appropriate data sources based on the last prompt. Applies a heuristic reduction at the end to reduce the number of data sources. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T648937779"] = "Automatically selects the appropriate data sources based on the last prompt. Applies a heuristic reduction at the end to reduce the number of data sources." +-- None of your selected data sources is available for the chosen provider. This answer was created without them. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T1696726639"] = "None of your selected data sources is available for the chosen provider. This answer was created without them." + -- This RAG process filters data sources, automatically selects appropriate sources, optionally allows manual source selection, retrieves data, and automatically validates the retrieval context. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3047786484"] = "This RAG process filters data sources, automatically selects appropriate sources, optionally allows manual source selection, retrieves data, and automatically validates the retrieval context." From 37d38df59757a1258c29d93a2dc61d101103a824 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 11:57:18 +0200 Subject: [PATCH 4/9] Restore the auto-save as the single owner of policy persistence --- .../DocumentAnalysisAssistant.razor.cs | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index f2a7ad96f..c87d6800f 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -256,12 +256,12 @@ protected override async Task OnInitializedAsync() private async Task AutoSave(bool force = false) { // - // A pending name store is a property of the settings, not of the selected policy: the name - // is written into its policy the very moment it is typed, so what is still outstanding is - // the store itself. It therefore outlives a form reset and a switch to another policy, and - // only a completed store clears it. + // A pending store is a property of the settings, not of the selected policy: the value has + // been written into its policy already, so what is still outstanding is the store itself. + // It therefore outlives a form reset and a switch to another policy, and only a completed + // store clears it. // - var hasChanges = this.policyNameStorePending; + var hasChanges = this.policyStorePending; if(this.selectedPolicy is { } policy) hasChanges |= this.ApplyFormToPolicy(policy, force); @@ -269,7 +269,7 @@ private async Task AutoSave(bool force = false) return; await this.SettingsManager.StoreSettings(); - this.policyNameStorePending = false; + this.policyStorePending = false; } /// @@ -333,15 +333,19 @@ private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force) private string policyName = string.Empty; /// - /// Whether a typed policy name still waits to be written to the settings file. + /// Whether an edit already applied to a policy still waits to be written to the settings file. /// /// - /// Typing a name applies it to its policy at once, so that the policy list shows the new name - /// right away -- which leaves nothing for the auto-save to compare the form against. This flag - /// is what tells it that a store is nevertheless due. It belongs to no particular policy: the - /// name has long arrived where it belongs, only the file has not caught up yet. + /// Some handlers apply their value to the selected policy at once, because the rest of the + /// assistant reads it back from there right away: the policy list has to show a new name while + /// it is being typed, and the provider preselection is recomputed from the policy, not from the + /// form. Doing so leaves the auto-save nothing to compare the form against -- form and policy + /// already agree -- so every such handler has to announce the store itself. That is what this + /// flag is for. It belongs to no particular policy: the value has long arrived where it + /// belongs, only the file has not caught up yet, which is why a form reset or a switch to + /// another policy does not clear it. Only a completed store does. /// - private bool policyNameStorePending; + private bool policyStorePending; private string policyDescription = string.Empty; private string policyAnalysisRules = string.Empty; private string policyOutputRules = string.Empty; @@ -521,7 +525,7 @@ private void PolicyNameWasChanged() return; this.selectedPolicy.PolicyName = this.policyName; - this.policyNameStorePending = true; + this.policyStorePending = true; } private async Task PolicyProtectionWasChanged(bool state) @@ -626,15 +630,21 @@ private Profile ResolveProfileSelection() private void PolicyAllowedToolsWasChanged(HashSet allowedToolIds) { this.policyAllowedToolIds = allowedToolIds; - if (this.selectedPolicy is not null) - this.selectedPolicy.AllowedToolIds = [..allowedToolIds]; + if (this.selectedPolicy is null) + return; + + this.selectedPolicy.AllowedToolIds = [..allowedToolIds]; + this.policyStorePending = true; } private void PolicyMinimumConfidenceWasChanged(ConfidenceLevel level) { this.policyMinimumProviderConfidence = level; if (this.selectedPolicy is not null) + { this.selectedPolicy.MinimumProviderConfidence = level; + this.policyStorePending = true; + } this.ApplyPolicyPreselection(); } @@ -646,6 +656,7 @@ private void PolicyPreselectedProviderWasChanged(string providerId) this.policyPreselectedProviderId = providerId; this.selectedPolicy.PreselectedProvider = providerId; + this.policyStorePending = true; this.ProviderSettings = Settings.Provider.NONE; this.ApplyPolicyPreselection(); } @@ -654,7 +665,10 @@ private void PolicyPreselectedProfileWasChanged(ProfilePreselection selection) { this.policyPreselectedProfile = selection; if (this.selectedPolicy is not null) + { this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile; + this.policyStorePending = true; + } this.CurrentProfile = this.ResolveProfileSelection(); } From 4aefd46f4ac90b2fdf25ee0261e784780c3e59c6 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 12:16:24 +0200 Subject: [PATCH 5/9] Guard the policy write-back against protected and enterprise policies --- .../DocumentAnalysisAssistant.razor.cs | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index c87d6800f..a6ef3bab5 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -286,7 +286,7 @@ private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force) // Enterprise configurations cannot be modified at all: if(policy.IsEnterpriseConfiguration) - return false; + return hasChanges; var canEditProtectedFields = force || (!policy.IsProtected && !this.policyIsProtected); if (!canEditProtectedFields) @@ -315,6 +315,20 @@ private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force) return hasChanges; } + /// + /// Whether the given policy may take over an edit of one of its protected fields right now. + /// + /// + /// The handlers which write their value straight into the policy have to ask this themselves. + /// ApplyFormToPolicy asks the same question, but it never gets to judge their fields: they have + /// already brought policy and form in line, so nothing is left for it to compare. The markup + /// disables those controls for a protected policy and an enterprise policy is always a protected + /// one, which is why nobody should ever reach a handler that way -- this keeps the rule in the + /// code as well, where the next handler will look for it. The form value counts alongside the + /// stored one, because the protection switch is flipped before the store which writes it has run. + /// + private bool AcceptsProtectedFieldEdits(DataDocumentAnalysisPolicy policy) => policy is { IsEnterpriseConfiguration: false, IsProtected: false } && !this.policyIsProtected; + private DataDocumentAnalysisPolicy? selectedPolicy; private bool policyIsProtected; private bool policyHidePolicyDefinition; @@ -630,19 +644,19 @@ private Profile ResolveProfileSelection() private void PolicyAllowedToolsWasChanged(HashSet allowedToolIds) { this.policyAllowedToolIds = allowedToolIds; - if (this.selectedPolicy is null) + if (this.selectedPolicy is not { } policy || !this.AcceptsProtectedFieldEdits(policy)) return; - this.selectedPolicy.AllowedToolIds = [..allowedToolIds]; + policy.AllowedToolIds = [..allowedToolIds]; this.policyStorePending = true; } private void PolicyMinimumConfidenceWasChanged(ConfidenceLevel level) { this.policyMinimumProviderConfidence = level; - if (this.selectedPolicy is not null) + if (this.selectedPolicy is { } policy && this.AcceptsProtectedFieldEdits(policy)) { - this.selectedPolicy.MinimumProviderConfidence = level; + policy.MinimumProviderConfidence = level; this.policyStorePending = true; } @@ -651,11 +665,11 @@ private void PolicyMinimumConfidenceWasChanged(ConfidenceLevel level) private void PolicyPreselectedProviderWasChanged(string providerId) { - if (this.selectedPolicy is null) + if (this.selectedPolicy is not { } policy || !this.AcceptsProtectedFieldEdits(policy)) return; this.policyPreselectedProviderId = providerId; - this.selectedPolicy.PreselectedProvider = providerId; + policy.PreselectedProvider = providerId; this.policyStorePending = true; this.ProviderSettings = Settings.Provider.NONE; this.ApplyPolicyPreselection(); From 7c5658e546a1188bf172359f633b4061ca0b8f99 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 12:24:45 +0200 Subject: [PATCH 6/9] Keep the settings file move on the calling thread and name the store overloads --- .../Settings/SettingsManager.cs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index 7dfceb31e..600567655 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -41,7 +41,12 @@ public sealed class SettingsManager /// Reading takes this as well as writing does, for two reasons. A read migrates and backs up /// what it found, so it writes the very files a store writes. And it re-evaluates whether /// writes are blocked at all, starting out by clearing that block: a store slipping through - /// that moment would overwrite the settings the block exists to protect. + /// that moment would overwrite the settings the block exists to protect.

+ /// What this does not do is guard the settings themselves. It guards the files: what one store + /// writes, the next one no longer has to fear. The configuration data behind them stays open to + /// everybody, and a store serializes it while the rest of the app goes on editing it -- a list + /// growing mid-serialization still throws. Whoever wants that answered needs one of their own; + /// this lock is not it. /// private readonly SemaphoreSlim settingsFileSemaphore = new(1, 1); @@ -335,8 +340,8 @@ public async Task StoreSettings() var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS); var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); - await this.StoreSettingsSnapshot(settingsJson, settingsPath); - await this.StoreCurrentVersionBackup(this.ConfigurationData.Version, settingsJson); + await this.StoreSerializedSettings(settingsJson, settingsPath); + await this.StoreSerializedVersionBackup(this.ConfigurationData.Version, settingsJson); } finally { @@ -349,9 +354,20 @@ public async Task StoreSettings() private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version)); private Task StoreCurrentVersionBackup(Data settingsData) => - this.StoreCurrentVersionBackup(settingsData.Version, JsonSerializer.Serialize(settingsData, JSON_OPTIONS)); + this.StoreSerializedVersionBackup(settingsData.Version, JsonSerializer.Serialize(settingsData, JSON_OPTIONS)); - private async Task StoreCurrentVersionBackup(Version settingsVersion, string settingsJson) + /// + /// Writes the backup file from settings which were serialized already. + /// + /// + /// The store hands the same JSON to this method and to the one writing the settings file, so + /// that both files say the same thing. Serializing twice cannot promise that: the configuration + /// data may well have changed in between, and the backup would then describe a state the + /// settings file never had. + /// + /// The version the serialized settings carry. + /// The serialized settings. + private async Task StoreSerializedVersionBackup(Version settingsVersion, string settingsJson) { if(settingsVersion != CURRENT_SETTINGS_VERSION) { @@ -360,14 +376,19 @@ private async Task StoreCurrentVersionBackup(Version settingsVersion, string set } var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION); - await this.StoreSettingsSnapshot(settingsJson, backupSettingsPath); + await this.StoreSerializedSettings(settingsJson, backupSettingsPath); this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'."); } private Task StoreSettingsSnapshot(Data settingsData, string settingsPath) => - this.StoreSettingsSnapshot(JsonSerializer.Serialize(settingsData, JSON_OPTIONS), settingsPath); + this.StoreSerializedSettings(JsonSerializer.Serialize(settingsData, JSON_OPTIONS), settingsPath); - private async Task StoreSettingsSnapshot(string settingsJson, string settingsPath) + /// + /// Writes settings which were serialized already to the given path. + /// + /// The serialized settings. + /// The file to write them to. + private async Task StoreSerializedSettings(string settingsJson, string settingsPath) { if(!Directory.Exists(ConfigDirectory)) { @@ -386,7 +407,7 @@ private async Task StoreSettingsSnapshot(string settingsJson, string settingsPat try { await File.WriteAllTextAsync(tempFile, settingsJson); - await Task.Run(() => File.Move(tempFile, settingsPath, true)); + File.Move(tempFile, settingsPath, true); } catch { From 1409cf9a4a48c9792124d0e40f6184ef3e9ee4d2 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 12:33:30 +0200 Subject: [PATCH 7/9] Add tests covering concurrent settings reads and writes --- app/Tests/Settings/SettingsStorageTests.cs | 159 +++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/Tests/Settings/SettingsStorageTests.cs diff --git a/app/Tests/Settings/SettingsStorageTests.cs b/app/Tests/Settings/SettingsStorageTests.cs new file mode 100644 index 000000000..236fef428 --- /dev/null +++ b/app/Tests/Settings/SettingsStorageTests.cs @@ -0,0 +1,159 @@ +using System.Text.Json; + +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Microsoft.Extensions.Logging.Abstractions; + +using Version = AIStudio.Settings.Version; + +namespace AIStudio.Tests.Settings; + +/// +/// Checks what settings operations do to each other when they overlap. +/// +/// +/// The settings are written from everywhere: a timer firing on its own thread, a dialog the user +/// just closed, a configuration plugin which arrived over the network. Nothing keeps two of those +/// from meeting, and what they must never leave behind is a settings file nobody can read -- it is +/// the file the app starts from the next morning. These tests arrange the meeting on purpose and +/// look at what is on the disk afterward. +/// +[TestFixture] +[NonParallelizable] +public sealed class SettingsStorageTests +{ + /// + /// How many operations are set against each other. + /// + /// + /// High enough that the operations genuinely overlap on any machine, low enough that the test + /// stays a test. A race which needs more than this to show up would not be one the app meets. + /// + private const int CONCURRENT_OPERATIONS = 50; + + private const string SETTINGS_FILENAME = "settings.json"; + + private const string BACKUP_FILENAME = "settings.v6.json"; + + private string? previousConfigDirectory; + private string? previousDataDirectory; + private string testDirectory = string.Empty; + + [SetUp] + public void PrepareTestDirectory() + { + // + // Both directories are static state of the whole application, which is why this fixture + // does not run alongside others. They are put back in the teardown so that a later test + // does not inherit a directory which is gone by then. + // + this.previousConfigDirectory = SettingsManager.ConfigDirectory; + this.previousDataDirectory = SettingsManager.DataDirectory; + + this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-settings-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.testDirectory); + + SettingsManager.ConfigDirectory = this.testDirectory; + SettingsManager.DataDirectory = this.testDirectory; + } + + [TearDown] + public void RemoveTestDirectory() + { + SettingsManager.ConfigDirectory = this.previousConfigDirectory; + SettingsManager.DataDirectory = this.previousDataDirectory; + + try + { + Directory.Delete(this.testDirectory, true); + } + catch (IOException) + { + // A temporary directory we could not remove says nothing about the code under test. + } + } + + [Test] + public async Task OverlappingStoresLeaveBothFilesReadable() + { + var settingsManager = CreateSettingsManager(); + await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings())); + + var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME); + var backupPath = Path.Combine(this.testDirectory, BACKUP_FILENAME); + + Assert.Multiple(() => + { + Assert.That(File.Exists(settingsPath), Is.True, "The settings file was never written."); + Assert.That(File.Exists(backupPath), Is.True, "The settings backup file was never written."); + Assert.That(ReadSettingsFile(settingsPath)?.Version, Is.EqualTo(Version.V6), "The settings file could not be read back."); + Assert.That(ReadSettingsFile(backupPath)?.Version, Is.EqualTo(Version.V6), "The settings backup file could not be read back."); + }); + } + + [Test] + public async Task OverlappingStoresLeaveNoTemporaryFilesBehind() + { + var settingsManager = CreateSettingsManager(); + await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings())); + + // + // Every store writes its settings next to the previous ones and renames afterwards. The + // temporary file carries a name of its own, so two stores cannot collide over it -- but a + // store which gave up halfway would leave one lying around, and the next start would find + // a configuration directory filling up with them. + // + var leftovers = Directory.GetFiles(this.testDirectory, "*.tmp-*").Select(Path.GetFileName).ToList(); + Assert.That(leftovers, Is.Empty, $"Temporary settings files were left behind: {string.Join(", ", leftovers)}."); + } + + [Test] + public async Task AStoreCannotSlipThroughWhileAReadReconsidersTheWriteBlock() + { + var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME); + + // + // Settings written by a newer app than this one. Reading them blocks every write, so that + // this app cannot replace settings it does not understand with the little it does. What + // makes this the interesting case is how a read arrives at that verdict: it clears the + // block first and only re-establishes it once it has seen the file. A store meeting that + // moment would find nothing standing in its way and overwrite the very file the block + // exists for -- which is why a read holds the same lock a store does. + // + await File.WriteAllTextAsync(settingsPath, """{"Version": "V99"}"""); + + var settingsManager = CreateSettingsManager(); + await settingsManager.TryReadSettingsSnapshot(); + Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The newer settings file did not block writes in the first place."); + + var operations = new List(); + for (var i = 0; i < CONCURRENT_OPERATIONS; i++) + { + operations.Add(settingsManager.StoreSettings()); + operations.Add(settingsManager.TryReadSettingsSnapshot()); + } + + await Task.WhenAll(operations); + + using var settingsDocument = JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath)); + Assert.Multiple(() => + { + Assert.That(settingsDocument.RootElement.GetProperty("Version").GetString(), Is.EqualTo("V99"), "A store overwrote the newer settings file while a read was reconsidering the write block."); + Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The write block did not survive the reads which re-established it."); + }); + } + + /// + /// Builds a settings manager the way these tests need it. + /// + /// + /// The rust service is handed in as null on purpose: neither storing nor reading settings ever + /// asks it anything. Only the active language is read through it, and that is not what is being + /// checked here. Should a future store reach for it, the test says so by failing loudly rather + /// than by quietly testing a different thing. + /// + private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!); + + private static Data? ReadSettingsFile(string settingsPath) => JsonSerializer.Deserialize(File.ReadAllText(settingsPath), SettingsManager.JSON_OPTIONS); +} \ No newline at end of file From a31dda671480db56eccafb9e3b877339cc3294ca Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 12:36:48 +0200 Subject: [PATCH 8/9] Complete the changelog for the document analysis fixes --- app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 7d8cb1d33..88aea0f42 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -29,9 +29,11 @@ - Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you. - Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files. - Improved organization-wide provider management: IT departments can now separately prevent users from adding chat, transcription, or embedding providers. The existing master setting still overrides all three provider-specific settings. +- Improved the provider selection throughout the assistants: when there is nothing to choose from, it now says why. Either you have not set up a provider yet, or none of yours is trusted enough for what you are doing. Before, the list was simply empty. - Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet. - Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had. -- Fixed the Document Analysis assistant becoming unresponsive when choosing an LLM provider. +- Fixed the Document Analysis assistant freezing while you edited a policy. It needed a change of yours to be saved in the background just as you were making the next one — picking a provider, for instance — which is why it hit some of you again and again and others never at all. +- Fixed a renamed policy losing its new name in the Document Analysis assistant. The name was kept only when you happened to change something else afterward. - Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability. - Fixed a model resold under a plain name not getting the abilities it really has. - Fixed image and video generation models showing up among the chat models. From 36a67a2e53d96c0ea1b289477855180e8e624491 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 16 Sep 2026 12:43:03 +0200 Subject: [PATCH 9/9] Fix the German wording of the provider and retrieval hints --- .../de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a2f7bed7d..a4500bad3 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 @@ -4222,7 +4222,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "Hier k UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "Bisher wurden keine LLM-Anbieter konfiguriert. Bitte fügen Sie einen Anbieter in den App-Einstellungen hinzu." -- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "Kein LLM-Anbieter erfüllt die Sicherheitsanforderungen. Bitte konfiguriere Sie einen gültigen Anbieter in den App-Einstellungen." +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "Kein LLM-Anbieter erfüllt die Vertrauensanforderungen. Bitte konfigurieren Sie einen geeigneten Anbieter in den App-Einstellungen." -- Audio input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audioeingabe möglich" @@ -11605,7 +11605,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T335338363 UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T1072508429"] = "Standardmäßiger Erweiterungsprozess" -- No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T2710880477"] = "Kein Anbieter ist vertrauenswürdig genug, um zu prüfen, welche Abschnitte zu deiner Frage passen. Diese Antwort verwendet alle gefundenen Abschnitte." +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T2710880477"] = "Kein Anbieter ist vertrauenswürdig genug, um zu prüfen, welche Abschnitte zu Ihrer Frage passen. Diese Antwort verwendet alle gefundenen Abschnitte." -- This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread. UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T3240406069"] = "Dies ist der Standard-Erweiterungsprozess, bei dem alle abgerufenen Kontexte verwendet werden, um den Chatverlauf zu ergänzen."