From 53f657da71ff0e88f3db554286958b3379e74938 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 16 Feb 2026 12:13:36 +0100 Subject: [PATCH] Improved the user language detection, especially for Linux (#663) --- .../Settings/SettingsManager.cs | 44 +++++-- .../wwwroot/changelog/v26.2.2.md | 1 + runtime/src/environment.rs | 115 +++++++++++++++++- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index 3b4ea704..d4bfc7e3 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -172,17 +172,31 @@ public sealed class SettingsManager { case LangBehavior.AUTO: var languageCode = await this.rustService.ReadUserLanguage(); - var languagePlugin = PluginFactory.RunningPlugins.FirstOrDefault(x => x is ILanguagePlugin langPlug && langPlug.IETFTag == languageCode); - if (languagePlugin is null) + var languagePlugins = PluginFactory.RunningPlugins.OfType().ToList(); + + if (!string.IsNullOrWhiteSpace(languageCode)) { - this.logger.LogWarning($"The language plugin for the language '{languageCode}' is not available."); - return PluginFactory.BaseLanguage; + var exactMatch = languagePlugins.FirstOrDefault(x => string.Equals(x.IETFTag, languageCode, StringComparison.OrdinalIgnoreCase)); + if (exactMatch is not null) + return exactMatch; + + var primaryLanguage = GetPrimaryLanguage(languageCode); + if (!string.IsNullOrWhiteSpace(primaryLanguage)) + { + var primaryLanguageMatch = languagePlugins + .Where(x => string.Equals(GetPrimaryLanguage(x.IETFTag), primaryLanguage, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.IETFTag, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (primaryLanguageMatch is not null) + { + this.logger.LogWarning($"No exact language plugin found for '{languageCode}'. Use language fallback '{primaryLanguageMatch.IETFTag}'."); + return primaryLanguageMatch; + } + } } - if (languagePlugin is ILanguagePlugin langPlugin) - return langPlugin; - - this.logger.LogError("The language plugin is not a language plugin."); + this.logger.LogWarning($"The language plugin for the language '{languageCode}' (normalized='{languageCode}') is not available."); return PluginFactory.BaseLanguage; case LangBehavior.MANUAL: @@ -204,6 +218,18 @@ public sealed class SettingsManager this.logger.LogError("The language behavior is unknown."); return PluginFactory.BaseLanguage; } + + private static string GetPrimaryLanguage(string localeTag) + { + if (string.IsNullOrWhiteSpace(localeTag)) + return string.Empty; + + var separatorIndex = localeTag.IndexOf('-'); + if (separatorIndex < 0) + return localeTag; + + return localeTag[..separatorIndex]; + } [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] public Provider GetPreselectedProvider(Tools.Components component, string? currentProviderId = null, bool usePreselectionBeforeCurrentProvider = false) @@ -365,4 +391,4 @@ public sealed class SettingsManager // Return the full name of the property, including the class name: return $"{typeof(TIn).Name}.{memberExpr.Member.Name}"; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md index af8eff4b..5823c1cf 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.2.2.md @@ -7,6 +7,7 @@ - Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users. - Improved the workspaces experience by using a different color for the delete button to avoid confusion. - Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition. +- Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably. - Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves. - Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver. - Upgraded dependencies. \ No newline at end of file diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index f3ccdc60..478fbff4 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -7,6 +7,8 @@ use serde::Serialize; use sys_locale::get_locale; use crate::api_token::APIToken; +const DEFAULT_LANGUAGE: &str = "en-US"; + /// The data directory where the application stores its data. pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); @@ -41,12 +43,115 @@ pub fn is_prod() -> bool { !is_dev() } +fn normalize_locale_tag(locale: &str) -> Option { + let trimmed = locale.trim(); + if trimmed.is_empty() { + return None; + } + + let without_encoding = trimmed + .split('.') + .next() + .unwrap_or(trimmed) + .split('@') + .next() + .unwrap_or(trimmed) + .trim(); + + if without_encoding.is_empty() { + return None; + } + + let normalized_delimiters = without_encoding.replace('_', "-"); + let mut segments = normalized_delimiters + .split('-') + .filter(|segment| !segment.is_empty()); + + let language = segments.next()?; + if language.eq_ignore_ascii_case("c") || language.eq_ignore_ascii_case("posix") { + return None; + } + + let language = language.to_ascii_lowercase(); + if language.len() < 2 || !language.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + + if let Some(region) = segments.next() { + if region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()) { + return Some(format!("{}-{}", language, region.to_ascii_uppercase())); + } + } + + Some(language) +} + +#[cfg(target_os = "linux")] +fn read_locale_from_environment() -> Option { + if let Ok(language) = env::var("LANGUAGE") { + for candidate in language.split(':') { + if let Some(locale) = normalize_locale_tag(candidate) { + info!("Detected user language from Linux environment variable 'LANGUAGE': '{}'.", locale); + return Some(locale); + } + } + } + + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = env::var(key) { + if let Some(locale) = normalize_locale_tag(&value) { + info!("Detected user language from Linux environment variable '{}': '{}'.", key, locale); + return Some(locale); + } + } + } + + None +} + +#[cfg(not(target_os = "linux"))] +fn read_locale_from_environment() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::normalize_locale_tag; + + #[test] + fn normalize_locale_tag_supports_common_linux_formats() { + assert_eq!(normalize_locale_tag("de_DE.UTF-8"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de_DE@euro"), Some(String::from("de-DE"))); + assert_eq!(normalize_locale_tag("de"), Some(String::from("de"))); + assert_eq!(normalize_locale_tag("en-US"), Some(String::from("en-US"))); + } + + #[test] + fn normalize_locale_tag_rejects_non_language_locales() { + assert_eq!(normalize_locale_tag("C"), None); + assert_eq!(normalize_locale_tag("C.UTF-8"), None); + assert_eq!(normalize_locale_tag("POSIX"), None); + assert_eq!(normalize_locale_tag(""), None); + } +} + #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { - get_locale().unwrap_or_else(|| { - warn!("Could not determine the system language. Use default 'en-US'."); - String::from("en-US") - }) + if let Some(locale) = get_locale() { + if let Some(normalized_locale) = normalize_locale_tag(&locale) { + info!("Detected user language from sys-locale: '{}'.", normalized_locale); + return normalized_locale; + } + + warn!("sys-locale returned an unusable locale value: '{}'.", locale); + } + + if let Some(locale) = read_locale_from_environment() { + return locale; + } + + warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE); + String::from(DEFAULT_LANGUAGE) } #[get("/system/enterprise/config/id")] @@ -300,4 +405,4 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String { } } } -} \ No newline at end of file +}