From 9ec687e22e103577fefbd562ce1e16a6024f250a Mon Sep 17 00:00:00 2001 From: hart_s3 Date: Mon, 27 Apr 2026 14:39:47 +0200 Subject: [PATCH 1/8] Add timeout handling and error logging across all providers --- .../Provider/BaseProvider.cs | 147 +++++++++++++++--- .../Provider/Google/ProviderGoogle.cs | 3 + .../Provider/Helmholtz/ProviderHelmholtz.cs | 53 ++++--- .../Provider/SelfHosted/ProviderSelfHosted.cs | 39 +++-- 4 files changed, 182 insertions(+), 60 deletions(-) diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index b36021ca..23857ac8 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -24,6 +24,8 @@ namespace AIStudio.Provider; /// public abstract class BaseProvider : IProvider, ISecretId { + private static readonly TimeSpan HTTP_TIMEOUT = TimeSpan.FromHours(1); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider)); /// @@ -74,6 +76,7 @@ protected BaseProvider(LLMProviders provider, string url, ILogger logger) // Set the base URL: this.HttpClient.BaseAddress = new(url); + this.HttpClient.Timeout = HTTP_TIMEOUT; } #region Handling of IProvider, which all providers must implement @@ -136,6 +139,28 @@ protected BaseProvider(LLMProviders provider, string url, ILogger logger) protected static ModelLoadResult FailedModelLoadResult(ModelLoadFailureReason failureReason, string? technicalDetails = null) => ModelLoadResult.Failure(failureReason, technicalDetails); + protected bool IsTimeoutException(Exception exception, CancellationToken token = default) + { + if (token.IsCancellationRequested) + return false; + + if (exception is TimeoutException) + return true; + + if (exception is OperationCanceledException) + return true; + + return exception.InnerException is not null && this.IsTimeoutException(exception.InnerException, token); + } + + protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( + Icons.Material.Filled.HourglassTop, + string.Format( + TB("The request to the LLM provider '{0}' (type={1}) timed out after 1 hour while {2}. Please try again or check whether the provider is still responding."), + this.InstanceName, + this.Provider, + action))); + protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch { not null => apiKeyProvisional, @@ -175,25 +200,34 @@ protected async Task LoadModelsResponse( else if (!string.IsNullOrWhiteSpace(secretKey)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - using var response = await this.HttpClient.SendAsync(request, token); - var responseBody = await response.Content.ReadAsStringAsync(token); - if (!response.IsSuccessStatusCode) - { - var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? GetDefaultModelLoadFailureReason(response); - return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); - } - try { - var parsedResponse = JsonSerializer.Deserialize(responseBody, jsonSerializerOptions ?? JSON_SERIALIZER_OPTIONS); - if (parsedResponse is null) - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + using var response = await this.HttpClient.SendAsync(request, token); + var responseBody = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + var failureReason = failureReasonSelector?.Invoke(response, responseBody) ?? GetDefaultModelLoadFailureReason(response); + return FailedModelLoadResult(failureReason, $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'"); + } - return SuccessfulModelLoadResult(modelFactory(parsedResponse)); + try + { + var parsedResponse = JsonSerializer.Deserialize(responseBody, jsonSerializerOptions ?? JSON_SERIALIZER_OPTIONS); + if (parsedResponse is null) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, "Model list response could not be deserialized."); + + return SuccessfulModelLoadResult(modelFactory(parsedResponse)); + } + catch (Exception e) + { + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, e.Message); + } } - catch (Exception e) + catch (Exception e) when (this.IsTimeoutException(e, token)) { - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, e.Message); + await this.SendTimeoutError("loading the available models"); + this.logger.LogError(e, "Timed out while loading models from provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } @@ -223,7 +257,18 @@ private async Task SendRequest(Func StreamChatCompletionInterna } catch(Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the chat completion request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("opening the chat response stream"); + this.logger.LogError(e, "Timed out while opening the chat completion stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}"); + } } if (streamReader is null) @@ -383,8 +440,21 @@ protected async IAsyncEnumerable StreamChatCompletionInterna } catch (Exception e) { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + if (token.IsCancellationRequested) + { + this.logger.LogWarning("The user canceled the chat completion stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName); + } + else if (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("reading the chat response stream"); + this.logger.LogError(e, "Timed out while reading the chat stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName); + } + else + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}"); + } + break; } @@ -505,8 +575,20 @@ protected async IAsyncEnumerable StreamResponsesInternal StreamResponsesInternal PerformStandardTranscriptionRequest(RequestedSecret } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("transcribing audio"); + this.logger.LogError("Failed to perform transcription request: '{Message}'.", e.Message); return string.Empty; } @@ -859,6 +957,9 @@ protected async Task>> PerformStandardTextEmb } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("creating embeddings"); + this.logger.LogError("Failed to perform embedding request: '{Message}'.", e.Message); return []; } diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index 03df306c..9aa2658e 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -135,6 +135,9 @@ public override async Task>> EmbedTextAsync(M } catch (Exception e) { + if (this.IsTimeoutException(e, token)) + await this.SendTimeoutError("creating embeddings"); + LOGGER.LogError("Failed to perform embedding request: '{Message}'.", e.Message); return []; } diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index 9f757eee..562d0f21 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -125,31 +125,40 @@ private async Task LoadModels(SecretStoreType storeType, Cancel if (string.IsNullOrWhiteSpace(secretKey)) return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, "No API key available for model loading."); - using var request = new HttpRequestMessage(HttpMethod.Get, "models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var response = await this.HttpClient.SendAsync(request, token); - var body = await response.Content.ReadAsStringAsync(token); - if (!response.IsSuccessStatusCode) - return FailedModelLoadResult(GetDefaultModelLoadFailureReason(response), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{body}'"); - try { - var modelResponse = JsonSerializer.Deserialize(body, JSON_SERIALIZER_OPTIONS); - return SuccessfulModelLoadResult(modelResponse.Data); - } - catch (JsonException e) - { - if (body.Contains("API key", StringComparison.InvariantCultureIgnoreCase)) - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, body); - - LOGGER.LogError(e, "Unexpected error while parsing models from Helmholtz API response. Status Code: {StatusCode}. Reason: {ReasonPhrase}. Response Body: '{ResponseBody}'", response.StatusCode, response.ReasonPhrase, body); - return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, body); + using var request = new HttpRequestMessage(HttpMethod.Get, "models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + + using var response = await this.HttpClient.SendAsync(request, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + return FailedModelLoadResult(GetDefaultModelLoadFailureReason(response), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{body}'"); + + try + { + var modelResponse = JsonSerializer.Deserialize(body, JSON_SERIALIZER_OPTIONS); + return SuccessfulModelLoadResult(modelResponse.Data); + } + catch (JsonException e) + { + if (body.Contains("API key", StringComparison.InvariantCultureIgnoreCase)) + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_OR_MISSING_API_KEY, body); + + LOGGER.LogError(e, "Unexpected error while parsing models from Helmholtz API response. Status Code: {StatusCode}. Reason: {ReasonPhrase}. Response Body: '{ResponseBody}'", response.StatusCode, response.ReasonPhrase, body); + return FailedModelLoadResult(ModelLoadFailureReason.INVALID_RESPONSE, body); + } + catch (Exception e) + { + LOGGER.LogError(e, "Unexpected error while loading models from Helmholtz API. Status Code: {StatusCode}. Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); + return FailedModelLoadResult(ModelLoadFailureReason.UNKNOWN, e.Message); + } } - catch (Exception e) + catch (Exception e) when (this.IsTimeoutException(e, token)) { - LOGGER.LogError(e, "Unexpected error while loading models from Helmholtz API. Status Code: {StatusCode}. Reason: {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); - return FailedModelLoadResult(ModelLoadFailureReason.UNKNOWN, e.Message); + await this.SendTimeoutError("loading the available models"); + LOGGER.LogError(e, "Timed out while loading models from Helmholtz provider '{ProviderInstanceName}'.", this.InstanceName); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index b3008209..e366657e 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -172,19 +172,28 @@ public override async Task GetTranscriptionModels(string? apiKe private async Task LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, CancellationToken token, string? apiKeyProvisional = null) { var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, true); - - using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models"); - if(secretKey is not null) - lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); - - using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); - if(!lmStudioResponse.IsSuccessStatusCode) - return FailedModelLoadResult(GetDefaultModelLoadFailureReason(lmStudioResponse), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}"); - - var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); - return SuccessfulModelLoadResult(lmStudioModelResponse.Data. - Where(model => !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && - filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) - .Select(n => new Provider.Model(n.Id, null))); + + try + { + using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models"); + if(secretKey is not null) + lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey); + + using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token); + if(!lmStudioResponse.IsSuccessStatusCode) + return FailedModelLoadResult(GetDefaultModelLoadFailureReason(lmStudioResponse), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}"); + + var lmStudioModelResponse = await lmStudioResponse.Content.ReadFromJsonAsync(token); + return SuccessfulModelLoadResult(lmStudioModelResponse.Data. + Where(model => !ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) && + filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture))) + .Select(n => new Provider.Model(n.Id, null))); + } + catch (Exception e) when (this.IsTimeoutException(e, token)) + { + await this.SendTimeoutError("loading the available models"); + LOGGER.LogError(e, "Timed out while loading models from self-hosted provider '{ProviderInstanceName}'.", this.InstanceName); + return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); + } } -} \ No newline at end of file +} From 57d60a7b85e7189970b30b85b01c22705bbf3535 Mon Sep 17 00:00:00 2001 From: hart_s3 Date: Tue, 28 Apr 2026 14:04:10 +0200 Subject: [PATCH 2/8] Add configurable timeout for LLM provider requests --- .../Plugins/configuration/plugin.lua | 4 ++ .../Provider/BaseProvider.cs | 37 +++++++++++++++++-- .../Settings/DataModel/DataApp.cs | 5 +++ .../Tools/PluginSystem/PluginConfiguration.cs | 3 ++ .../PluginSystem/PluginFactory.Loading.cs | 4 ++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 6cd5858d..af7c6ae0 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -212,6 +212,10 @@ CONFIG["SETTINGS"] = {} -- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8" -- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1" +-- Configure the HTTP timeout for requests to LLM providers, in seconds. +-- The default is 3600 (1 hour). +-- CONFIG["SETTINGS"]["DataApp.ProviderHttpTimeoutSeconds"] = 3600 + -- Example chat templates for this configuration: CONFIG["CHAT_TEMPLATES"] = {} diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 23857ac8..ebb0feda 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -24,9 +24,10 @@ namespace AIStudio.Provider; /// public abstract class BaseProvider : IProvider, ISecretId { - private static readonly TimeSpan HTTP_TIMEOUT = TimeSpan.FromHours(1); + private const int DEFAULT_HTTP_TIMEOUT_SECONDS = 3600; private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider)); + private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); /// /// The HTTP client to use it for all requests. @@ -76,7 +77,7 @@ protected BaseProvider(LLMProviders provider, string url, ILogger logger) // Set the base URL: this.HttpClient.BaseAddress = new(url); - this.HttpClient.Timeout = HTTP_TIMEOUT; + this.HttpClient.Timeout = GetProviderHttpTimeout(); } #region Handling of IProvider, which all providers must implement @@ -156,11 +157,41 @@ protected bool IsTimeoutException(Exception exception, CancellationToken token = protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( Icons.Material.Filled.HourglassTop, string.Format( - TB("The request to the LLM provider '{0}' (type={1}) timed out after 1 hour while {2}. Please try again or check whether the provider is still responding."), + TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."), this.InstanceName, this.Provider, + GetProviderHttpTimeoutDescription(), action))); + private static TimeSpan GetProviderHttpTimeout() + { + var seconds = SETTINGS_MANAGER.ConfigurationData.App.ProviderHttpTimeoutSeconds; + if (seconds <= 0) + seconds = DEFAULT_HTTP_TIMEOUT_SECONDS; + + return TimeSpan.FromSeconds(seconds); + } + + private static string GetProviderHttpTimeoutDescription() + { + var timeout = GetProviderHttpTimeout(); + + if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0) + { + var hours = (int)timeout.TotalHours; + return hours == 1 ? "1 hour" : $"{hours} hours"; + } + + if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0) + { + var minutes = (int)timeout.TotalMinutes; + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + + var seconds = (int)timeout.TotalSeconds; + return seconds == 1 ? "1 second" : $"{seconds} seconds"; + } + protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch { not null => apiKeyProvisional, diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 3a62164b..1b25ba88 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -94,6 +94,11 @@ public DataApp() : this(null) /// public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); + /// + /// The HTTP timeout in seconds for requests to LLM providers. + /// + public int ProviderHttpTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ProviderHttpTimeoutSeconds, 3600); + /// /// Should the user be allowed to add providers? /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index da504b29..35b2a114 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -140,6 +140,9 @@ private bool TryProcessConfiguration(bool dryRun, out string message) // Config: global voice recording shortcut ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun); + + // Config: timeout for HTTP requests to providers + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ProviderHttpTimeoutSeconds, this.Id, settingsTable, dryRun); // Handle configured LLM providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index aedc7f7e..561ae362 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -241,6 +241,10 @@ plugin.Type is PluginType.CONFIGURATION && if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + // Check for the provider HTTP timeout: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ProviderHttpTimeoutSeconds, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + // Check if audit is required before it can be activated if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; From 6894f79f2e15950536407f0a9b4b95462c8142c9 Mon Sep 17 00:00:00 2001 From: hart_s3 Date: Mon, 4 May 2026 12:07:00 +0200 Subject: [PATCH 3/8] Updated changelog --- app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md index 3ff00c6a..21f31398 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md @@ -39,4 +39,5 @@ - Fixed security issues in the native app runtime by strengthening how AI Studio creates and protects the secret values used for its internal secure connection. - Updated several security-sensitive Rust dependencies in the native runtime to address known vulnerabilities. - Updated .NET to v9.0.15 -- Updated dependencies \ No newline at end of file +- Updated dependencies +- Updated the timeout from a few seconds to 1 hour and configured it to be adjustable via the Enterprise settings. \ No newline at end of file From b6683b8d4280d907f16b9da049e6b6ed016dad72 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 19 May 2026 13:43:53 +0200 Subject: [PATCH 4/8] Revert changelog --- app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md index 21f31398..3ff00c6a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.4.1.md @@ -39,5 +39,4 @@ - Fixed security issues in the native app runtime by strengthening how AI Studio creates and protects the secret values used for its internal secure connection. - Updated several security-sensitive Rust dependencies in the native runtime to address known vulnerabilities. - Updated .NET to v9.0.15 -- Updated dependencies -- Updated the timeout from a few seconds to 1 hour and configured it to be adjustable via the Enterprise settings. \ No newline at end of file +- Updated dependencies \ No newline at end of file From 5c4778d4c0d1a2f33f62b030bfbbc680b38529e3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 21 May 2026 15:52:18 +0200 Subject: [PATCH 5/8] Fixed URL --- app/MindWork AI Studio/Plugins/configuration/plugin.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 3cf0ae0f..928627b9 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -220,7 +220,7 @@ CONFIG["SETTINGS"] = {} -- CONFIG["SETTINGS"]["DataApp.PreviewVisibility"] = "NONE" -- Configure the enabled preview features: --- Allowed values are can be found in https://github.com/MindWorkAI/AI-Studio/app/MindWork%20AI%20Studio/Settings/DataModel/PreviewFeatures.cs +-- Allowed values are can be found in https://github.com/MindWorkAI/AI-Studio/blob/main/app/MindWork%20AI%20Studio/Settings/DataModel/PreviewFeatures.cs -- Examples are PRE_WRITER_MODE_2024 and PRE_RAG_2024. -- CONFIG["SETTINGS"]["DataApp.EnabledPreviewFeatures"] = { "PRE_RAG_2024" } From 6c7274965815cc829cf8d81094524c2062ea7cf9 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 21 May 2026 16:21:39 +0200 Subject: [PATCH 6/8] Improved HTTP timeout configuration --- .../Chat/IImageSourceExtensions.cs | 8 +- .../Plugins/configuration/plugin.lua | 4 +- .../Provider/BaseProvider.cs | 105 +++++------------- .../Provider/Helmholtz/ProviderHelmholtz.cs | 2 +- .../Provider/SelfHosted/ProviderSelfHosted.cs | 2 +- .../Settings/DataModel/DataApp.cs | 4 +- .../Tools/ERIClient/ERIClientBase.cs | 5 +- .../Tools/ExternalHttpClientTimeout.cs | 77 +++++++++++++ .../Tools/PluginSystem/PluginConfiguration.cs | 4 +- .../PluginSystem/PluginFactory.Download.cs | 4 +- .../PluginSystem/PluginFactory.Loading.cs | 4 +- .../wwwroot/changelog/v26.5.5.md | 1 + 12 files changed, 123 insertions(+), 97 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs diff --git a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs index c6461643..6c3f204f 100644 --- a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs +++ b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs @@ -89,8 +89,10 @@ private static MIMEType DeriveMIMETypeFromExtension(string extension) case ContentImageSource.URL: { - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, token); + using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(); + using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); + var timeoutToken = timeoutTokenSource.Token; + using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, timeoutToken); if(response.IsSuccessStatusCode) { // Read the length of the content: @@ -101,7 +103,7 @@ private static MIMEType DeriveMIMETypeFromExtension(string extension) return (success: false, string.Empty); } - var bytes = await response.Content.ReadAsByteArrayAsync(token); + var bytes = await response.Content.ReadAsByteArrayAsync(timeoutToken); return (success: true, Convert.ToBase64String(bytes)); } diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 928627b9..ef98a1e6 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -260,9 +260,9 @@ CONFIG["SETTINGS"] = {} -- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8" -- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1" --- Configure the HTTP timeout for requests to LLM providers, in seconds. +-- Configure the HTTP timeout for external requests, in seconds. -- The default is 3600 (1 hour). --- CONFIG["SETTINGS"]["DataApp.ProviderHttpTimeoutSeconds"] = 3600 +-- CONFIG["SETTINGS"]["DataApp.HttpClientTimeoutSeconds"] = 3600 -- Example chat templates for this configuration: CONFIG["CHAT_TEMPLATES"] = {} diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index ebb0feda..123c4a86 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -24,15 +24,12 @@ namespace AIStudio.Provider; /// public abstract class BaseProvider : IProvider, ISecretId { - private const int DEFAULT_HTTP_TIMEOUT_SECONDS = 3600; - private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider)); - private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService(); /// /// The HTTP client to use it for all requests. /// - protected readonly HttpClient HttpClient = new(); + protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(); /// /// The logger to use. @@ -77,7 +74,6 @@ protected BaseProvider(LLMProviders provider, string url, ILogger logger) // Set the base URL: this.HttpClient.BaseAddress = new(url); - this.HttpClient.Timeout = GetProviderHttpTimeout(); } #region Handling of IProvider, which all providers must implement @@ -145,13 +141,7 @@ protected bool IsTimeoutException(Exception exception, CancellationToken token = if (token.IsCancellationRequested) return false; - if (exception is TimeoutException) - return true; - - if (exception is OperationCanceledException) - return true; - - return exception.InnerException is not null && this.IsTimeoutException(exception.InnerException, token); + return ExternalHttpClientTimeout.IsTimeoutException(exception, token); } protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( @@ -160,38 +150,9 @@ protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError( TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."), this.InstanceName, this.Provider, - GetProviderHttpTimeoutDescription(), + ExternalHttpClientTimeout.GetTimeoutDescription(), action))); - private static TimeSpan GetProviderHttpTimeout() - { - var seconds = SETTINGS_MANAGER.ConfigurationData.App.ProviderHttpTimeoutSeconds; - if (seconds <= 0) - seconds = DEFAULT_HTTP_TIMEOUT_SECONDS; - - return TimeSpan.FromSeconds(seconds); - } - - private static string GetProviderHttpTimeoutDescription() - { - var timeout = GetProviderHttpTimeout(); - - if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0) - { - var hours = (int)timeout.TotalHours; - return hours == 1 ? "1 hour" : $"{hours} hours"; - } - - if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0) - { - var minutes = (int)timeout.TotalMinutes; - return minutes == 1 ? "1 minute" : $"{minutes} minutes"; - } - - var seconds = (int)timeout.TotalSeconds; - return seconds == 1 ? "1 second" : $"{seconds} seconds"; - } - protected async Task GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch { not null => apiKeyProvisional, @@ -266,12 +227,14 @@ protected async Task LoadModelsResponse( /// Sends a request and handles rate limiting by exponential backoff. /// /// A function that builds the request. - /// The cancellation token. + /// The user cancellation token. + /// The token to use for the HTTP request. /// The status object of the request. - private async Task SendRequest(Func> requestBuilder, CancellationToken token = default) + private async Task SendRequest(Func> requestBuilder, CancellationToken userCancellationToken = default, CancellationToken requestCancellationToken = default) { const int MAX_RETRIES = 6; const double RETRY_DELAY_SECONDS = 4; + var effectiveCancellationToken = requestCancellationToken.CanBeCanceled ? requestCancellationToken : userCancellationToken; var retry = 0; var response = default(HttpResponseMessage); @@ -291,9 +254,9 @@ private async Task SendRequest(Func SendRequest(Func SendRequest(Func= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage)) @@ -399,10 +362,12 @@ protected async IAsyncEnumerable StreamChatCompletionInterna var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); StreamReader? streamReader = null; + using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token); + var timeoutToken = timeoutTokenSource.Token; try { // Send the request using exponential backoff: - var responseData = await this.SendRequest(requestBuilder, token); + var responseData = await this.SendRequest(requestBuilder, token, timeoutToken); if(responseData.IsFailedAfterAllRetries) { this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}"); @@ -410,7 +375,7 @@ protected async IAsyncEnumerable StreamChatCompletionInterna } // Open the response stream: - var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token); + var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken); // Add a stream reader to read the stream, line by line: streamReader = new StreamReader(providerStream); @@ -441,18 +406,6 @@ protected async IAsyncEnumerable StreamChatCompletionInterna // while (true) { - try - { - if(streamReader.EndOfStream) - break; - } - catch (Exception e) - { - await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); - this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); - break; - } - // Check if the token is canceled: if (token.IsCancellationRequested) { @@ -467,7 +420,7 @@ protected async IAsyncEnumerable StreamChatCompletionInterna string? line; try { - line = await streamReader.ReadLineAsync(token); + line = await streamReader.ReadLineAsync(timeoutToken); } catch (Exception e) { @@ -489,6 +442,9 @@ protected async IAsyncEnumerable StreamChatCompletionInterna break; } + if (line is null) + break; + // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; @@ -588,10 +544,12 @@ protected async IAsyncEnumerable StreamResponsesInternal StreamResponsesInternal StreamResponsesInternal StreamResponsesInternal StreamResponsesInternal LoadModels(SecretStoreType storeType, Cancel return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index e366657e..598cb2f3 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -196,4 +196,4 @@ private async Task LoadModels(SecretStoreType storeType, string return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message); } } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 1b25ba88..ad027064 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -95,9 +95,9 @@ public DataApp() : this(null) public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); /// - /// The HTTP timeout in seconds for requests to LLM providers. + /// The HTTP timeout in seconds for external HTTP clients. /// - public int ProviderHttpTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ProviderHttpTimeoutSeconds, 3600); + public int HttpClientTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HttpClientTimeoutSeconds, ExternalHttpClientTimeout.DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS); /// /// Should the user be allowed to add providers? diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs index 338401e3..389a90e3 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs @@ -23,10 +23,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable } }; - protected readonly HttpClient HttpClient = new() - { - BaseAddress = new Uri($"{dataSource.Hostname}:{dataSource.Port}"), - }; + protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}")); protected string SecurityToken = string.Empty; diff --git a/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs b/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs new file mode 100644 index 00000000..b467f2c5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/ExternalHttpClientTimeout.cs @@ -0,0 +1,77 @@ +using AIStudio.Settings; + +namespace AIStudio.Tools; + +/// +/// Provides utility methods to standardize the management of HTTP client timeouts +/// across various components in the application. +/// +public static class ExternalHttpClientTimeout +{ + public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600; + + public static HttpClient CreateHttpClient(Uri? baseAddress = null) + { + var httpClient = new HttpClient(); + Configure(httpClient, baseAddress); + return httpClient; + } + + public static string GetTimeoutDescription() + { + var timeout = GetTimeout(); + + if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0) + { + var hours = (int)timeout.TotalHours; + return hours == 1 ? "1 hour" : $"{hours} hours"; + } + + if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0) + { + var minutes = (int)timeout.TotalMinutes; + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + } + + var seconds = (int)timeout.TotalSeconds; + return seconds == 1 ? "1 second" : $"{seconds} seconds"; + } + + public static CancellationTokenSource CreateTimeoutTokenSource(CancellationToken cancellationToken) + { + var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutTokenSource.CancelAfter(GetTimeout()); + return timeoutTokenSource; + } + + public static bool IsTimeoutException(Exception exception, CancellationToken userCancellationToken = default) + { + if (userCancellationToken.IsCancellationRequested) + return false; + + if (exception is TimeoutException) + return true; + + if (exception is OperationCanceledException) + return true; + + return exception.InnerException is not null && IsTimeoutException(exception.InnerException, userCancellationToken); + } + + private static TimeSpan GetTimeout() + { + var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService(); + var seconds = settingsManager.ConfigurationData.App.HttpClientTimeoutSeconds; + if (seconds <= 0) + seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS; + + return TimeSpan.FromSeconds(seconds); + } + + private static void Configure(HttpClient httpClient, Uri? baseAddress = null) + { + httpClient.Timeout = GetTimeout(); + if (baseAddress is not null) + httpClient.BaseAddress = baseAddress; + } +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 337526fc..dd422c06 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -172,8 +172,8 @@ private bool TryProcessConfiguration(bool dryRun, out string message) // Config: global voice recording shortcut ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun); - // Config: timeout for HTTP requests to providers - ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ProviderHttpTimeoutSeconds, this.Id, settingsTable, dryRun); + // Config: timeout for external HTTP requests + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun); // Handle configured LLM providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index 9b56e3af..daf77fb0 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -15,7 +15,7 @@ public static partial class PluginFactory var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl; var downloadUrl = $"{serverUrl}/{configPlugId}.zip"; - using var http = new HttpClient(); + using var http = ExternalHttpClientTimeout.CreateHttpClient(); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) @@ -52,7 +52,7 @@ public static async Task TryDownloadingConfigPluginAsync(Guid configPlugId try { await LockHotReloadAsync(); - using var httpClient = new HttpClient(); + using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(); var response = await httpClient.GetAsync(downloadUrl, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index f97f6a9b..b0dfd89d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -245,8 +245,8 @@ plugin.Type is PluginType.CONFIGURATION && if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; - // Check for the provider HTTP timeout: - if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ProviderHttpTimeoutSeconds, AVAILABLE_PLUGINS)) + // Check for the external HTTP client timeout: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; // Check if audit is required before it can be activated diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index b8d5f9a1..67c080e9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -2,6 +2,7 @@ - Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation. - Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. - Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually. +- Added an option to configure the timeout setting for all requests. This is useful when you have a slow network connection, or you have to work with slow AI servers. It is also possible to configure this timeout for an entire organization using configuration plugins. - Added the username to the information page to make organization support easier when users share their screen. - Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. - Improved the Pandoc management and detection process to make it more reliable. From bb68483cd3405d93dd6ddbd38409ed233e6304b2 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 21 May 2026 16:39:24 +0200 Subject: [PATCH 7/8] Added an UI to let the the user configure the timeout --- .../Settings/SettingsPanelApp.razor | 1 + .../Provider/BaseProvider.cs | 24 +++++++++++++++++++ .../Tools/ExternalHttpClientTimeout.cs | 10 +++++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index c5ae753f..2237ebb0 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -14,6 +14,7 @@ + diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 123c4a86..d3bcd005 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -406,6 +406,18 @@ protected async IAsyncEnumerable StreamChatCompletionInterna // while (true) { + try + { + if(streamReader.EndOfStream) + break; + } + catch (Exception e) + { + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message))); + this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}"); + break; + } + // Check if the token is canceled: if (token.IsCancellationRequested) { @@ -588,6 +600,18 @@ protected async IAsyncEnumerable StreamResponsesInternal public static class ExternalHttpClientTimeout { + public const int MIN_HTTP_CLIENT_TIMEOUT_SECONDS = 120; + public const int MAX_HTTP_CLIENT_TIMEOUT_SECONDS = 3600; public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600; + private static readonly Lazy SETTINGS_MANAGER = new(() => Program.SERVICE_PROVIDER.GetRequiredService()); + public static HttpClient CreateHttpClient(Uri? baseAddress = null) { var httpClient = new HttpClient(); @@ -60,11 +64,11 @@ public static bool IsTimeoutException(Exception exception, CancellationToken use private static TimeSpan GetTimeout() { - var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService(); - var seconds = settingsManager.ConfigurationData.App.HttpClientTimeoutSeconds; + var seconds = SETTINGS_MANAGER.Value.ConfigurationData.App.HttpClientTimeoutSeconds; if (seconds <= 0) seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS; + seconds = Math.Clamp(seconds, MIN_HTTP_CLIENT_TIMEOUT_SECONDS, MAX_HTTP_CLIENT_TIMEOUT_SECONDS); return TimeSpan.FromSeconds(seconds); } @@ -74,4 +78,4 @@ private static void Configure(HttpClient httpClient, Uri? baseAddress = null) if (baseAddress is not null) httpClient.BaseAddress = baseAddress; } -} +} \ No newline at end of file From 8c9e6944d632faecd53ddd905b87d659f7481c5d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 21 May 2026 16:39:32 +0200 Subject: [PATCH 8/8] Updated I18N --- app/MindWork AI Studio/Assistants/I18N/allTexts.lua | 12 ++++++++++++ .../plugin.lua | 12 ++++++++++++ .../plugin.lua | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 0828bcbc..40750dad 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2644,6 +2644,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence." +-- seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "seconds" + -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled." @@ -2692,6 +2695,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] -- Spellchecking is enabled UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] = "Spellchecking is enabled" +-- Request timeout +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3569531009"] = "Request timeout" + -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" @@ -2719,6 +2725,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] -- Select a transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4174666315"] = "Select a transcription provider" +-- How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4192032183"] = "How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads." + -- Navigation bar behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Navigation bar behavior" @@ -6436,6 +6445,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Your stage directions" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'" +-- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding." + -- Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1487597412"] = "Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'" 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 f499a093..587747ae 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 @@ -2646,6 +2646,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Möchten Sie eines ihrer Profile als Standard für die gesamte App festlegen? Wenn Sie einem Assistenten ein anderes Profil zuweisen, hat dieses immer Vorrang." +-- seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "Sekunden" + -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Wählen Sie für die Transkription Ihrer Stimme einen Anbieter für Transkriptionen aus. Ohne einen ausgewählten Anbieter wird die Diktier- und Transkriptions-Funktion deaktiviert." @@ -2694,6 +2697,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] -- Spellchecking is enabled UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] = "Rechtschreibprüfung ist aktiviert" +-- Request timeout +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3569531009"] = "Zeitüberschreitung bei der Anfrage" + -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App-Einstellungen" @@ -2721,6 +2727,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] -- Select a transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4174666315"] = "Wählen Sie einen Transkriptionsanbieter aus" +-- How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4192032183"] = "Wie lange AI Studio auf externe HTTP-Anfragen wartet, z. B. an KI-Anbieter, Einbettungen, Transkription, ERI-Datenquellen und Downloads von Enterprise-Konfigurationen." + -- Navigation bar behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Verhalten der Navigationsleiste" @@ -6438,6 +6447,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Ihre Regieanweisungen" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "Wir haben versucht, mit dem LLM-Anbieter „{0}“ (Typ={1}) zu kommunizieren. Der Server ist möglicherweise nicht erreichbar oder hat Probleme. Die Nachricht des Anbieters lautet: „{2}“" +-- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "Die Anfrage an den LLM-Anbieter „{0}“ (Typ={1}) hat nach {2} während „{3}“ das Zeitlimit überschritten. Bitte versuchen Sie es erneut oder prüfen Sie, ob der Anbieter noch antwortet." + -- Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1487597412"] = "Beim Versuch, die Antwort des LLM-Anbieters '{0}' zu streamen, sind Probleme aufgetreten. Die Meldung lautet: '{1}'" 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 3726cd6b..691e965f 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 @@ -2646,6 +2646,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence." +-- seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "seconds" + -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled." @@ -2694,6 +2697,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] -- Spellchecking is enabled UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3165555978"] = "Spellchecking is enabled" +-- Request timeout +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3569531009"] = "Request timeout" + -- App Options UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3577148634"] = "App Options" @@ -2721,6 +2727,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] -- Select a transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4174666315"] = "Select a transcription provider" +-- How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4192032183"] = "How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads." + -- Navigation bar behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T602293588"] = "Navigation bar behavior" @@ -6438,6 +6447,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T779923726"] = "Your stage directions" -- We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1000247110"] = "We tried to communicate with the LLM provider '{0}' (type={1}). The server might be down or having issues. The provider message is: '{2}'" +-- The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding. +UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1069211263"] = "The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding." + -- Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}' UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T1487597412"] = "Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"