diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml index 05a4e49c9..0357aac61 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -114,6 +114,21 @@ + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs index 81ed218ce..2242a20c5 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.UI; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; @@ -342,6 +343,7 @@ _localAiSelectedGpuCapacityBytes is { } capacityBytes && LocalAiInstallReviewCard.Visibility = Visibility.Visible; LocalAiUnavailablePanel.Visibility = Visibility.Collapsed; LocalAiToggle.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability(isAvailable: true); _localAiSelectionEligible = eligibility.Status == LocalInferenceEligibilityStatus.Eligible; _config!.LocalAi.SelectedModelId ??= eligibility.Plan!.Model.Id; PopulateLocalAiModels(); @@ -377,41 +379,31 @@ private void ShowLocalAiUnavailable(string reason) _suppressLocalAiToggle = true; LocalAiToggle.IsOn = false; _suppressLocalAiToggle = false; - LocalAiToggle.Visibility = Visibility.Collapsed; + LocalAiToggle.Visibility = Visibility.Visible; LocalAiDetailsPanel.Visibility = Visibility.Collapsed; _localAiUnavailableReason = reason; LocalAiUnavailablePanel.Visibility = Visibility.Visible; LocalAiInstallReviewCard.Visibility = Visibility.Visible; + SetLocalAiOptionAvailability(isAvailable: false); _config!.LocalAi.Enabled = false; _config.SkipWizard = _skipWizardWithoutLocalAi; ApplySetupReviewSummary(_config); } private static string DescribeLocalAiUnavailable(LocalInferenceEligibilityResult eligibility) => - eligibility.SelectionFailureCode switch - { - LocalInferenceSelectionFailureCode.RuntimeUnavailable => - "This Local AI release does not include a native llama-server runtime for the detected Windows architecture.", - LocalInferenceSelectionFailureCode.NoNvidiaGpu => - "No NVIDIA GPU was reported by the NVIDIA driver. Install or repair the NVIDIA driver, then try setup again.", - LocalInferenceSelectionFailureCode.UnknownModel => - "The selected model is not available in this Local AI release.", - _ => eligibility.FailureCode switch - { - LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete => - "OpenClaw could not read a stable NVIDIA GPU identifier, memory, driver, or CUDA capability.", - LocalInferenceEligibilityFailureCode.InsufficientGpuMemory => - $"{eligibility.Plan?.Model.DisplayName ?? "The selected model"} requires " + - $"{FormatSize(eligibility.RequiredTotalMemoryBytes)} of GPU memory for model weights, KV cache, and runtime workspace. " + - $"OpenClaw detected {FormatOptionalSize(eligibility.DetectedTotalMemoryBytes)}.", - LocalInferenceEligibilityFailureCode.DriverTooOld => - $"NVIDIA driver {eligibility.SelectedGpu?.DriverVersion ?? "unknown"} was detected. " + - $"Local AI requires version {LocalInferenceEligibility.MinimumNvidiaDriverVersion} or newer.", - LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow => - "The NVIDIA driver does not provide CUDA 13 support. A separate CUDA Toolkit is not required.", - _ => "OpenClaw could not verify the Local AI requirements on this system.", - }, - }; + LocalInferenceEligibilityDiagnostics.DescribeUnavailable(eligibility); + + private void SetLocalAiOptionAvailability(bool isAvailable) + { + LocalAiOptionContent.IsHitTestVisible = isAvailable; + LocalAiOptionContent.Opacity = isAvailable ? 1 : 0.55; + LocalAiToggle.IsEnabled = isAvailable; + LocalAiModelSelector.IsEnabled = isAvailable; + LocalAiNetworkingConsentCheckBox.IsEnabled = isAvailable; + AutomationProperties.SetHelpText( + LocalAiOptionContent, + isAvailable ? string.Empty : "Unavailable because this PC does not meet the Local AI requirements."); + } private void LocalAiUnavailableDetails_Click(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml index 96d0ca872..700140f92 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml @@ -65,30 +65,27 @@ Style="{StaticResource CaptionTextBlockStyle}" Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}" /> + + + + + + - - - - - - - diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs index 7c74afe20..b6e10166d 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Composition; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Hosting; using Microsoft.UI.Xaml.Navigation; @@ -63,12 +64,13 @@ private async Task DetectLocalAiAvailabilityAsync() LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate( hardware, config.LocalAi.SelectedModelId); - if (!eligibility.CanInstall || eligibility.SelectedGpu is not { } gpu) + if (!eligibility.CanInstall || eligibility.SelectedGpu is null) return; - LocalAiAvailabilityText.Text = - $"{gpu.Name} detected. Install a local gateway to use local AI inference on this PC."; - LocalAiAvailabilityPanel.Visibility = Visibility.Visible; + LocalAiAvailabilityBadge.Visibility = Visibility.Visible; + AutomationProperties.SetName( + InstallChoice, + "Install a local gateway (WSL), recommended, Local AI available"); } private void StartMascotBreatheAnimation() diff --git a/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibilityDiagnostics.cs b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibilityDiagnostics.cs new file mode 100644 index 000000000..e5f3a787f --- /dev/null +++ b/src/OpenClaw.Shared/Inference/Catalog/LocalInferenceEligibilityDiagnostics.cs @@ -0,0 +1,40 @@ +namespace OpenClaw.Shared.Inference.Catalog; + +public static class LocalInferenceEligibilityDiagnostics +{ + public static string DescribeUnavailable(LocalInferenceEligibilityResult eligibility) + { + ArgumentNullException.ThrowIfNull(eligibility); + + return eligibility.SelectionFailureCode switch + { + LocalInferenceSelectionFailureCode.RuntimeUnavailable => + "This Local AI release does not include a native llama-server runtime for the detected Windows architecture.", + LocalInferenceSelectionFailureCode.NoNvidiaGpu => + "No NVIDIA GPU was reported by the NVIDIA driver. Install or repair the NVIDIA driver, then try setup again.", + LocalInferenceSelectionFailureCode.UnknownModel => + "The selected model is not available in this Local AI release.", + _ => eligibility.FailureCode switch + { + LocalInferenceEligibilityFailureCode.HardwareFactsIncomplete => + "OpenClaw could not read a stable NVIDIA GPU identifier, memory, driver, or CUDA capability.", + LocalInferenceEligibilityFailureCode.InsufficientGpuMemory => + $"{eligibility.Plan?.Model.DisplayName ?? "The selected model"} requires " + + $"{FormatSize(eligibility.RequiredTotalMemoryBytes)} of GPU memory for model weights, KV cache, and runtime workspace. " + + $"OpenClaw detected {FormatOptionalSize(eligibility.DetectedTotalMemoryBytes)}.", + LocalInferenceEligibilityFailureCode.DriverTooOld => + $"NVIDIA driver {eligibility.SelectedGpu?.DriverVersion ?? "unknown"} was detected. " + + $"Local AI requires version {LocalInferenceEligibility.MinimumNvidiaDriverVersion} or newer.", + LocalInferenceEligibilityFailureCode.CudaCapabilityTooLow => + "The NVIDIA driver does not provide CUDA 13 support. A separate CUDA Toolkit is not required.", + _ => "OpenClaw could not verify the Local AI requirements on this system.", + }, + }; + } + + private static string FormatSize(long bytes) => + $"{bytes / 1_000_000_000d:0.#} GB"; + + private static string FormatOptionalSize(long? bytes) => + bytes is { } value ? FormatSize(value) : "an unknown amount"; +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml index 83157c6b6..cef53e1fa 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml @@ -12,9 +12,39 @@ Style="{StaticResource CaptionTextBlockStyle}" Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"/> - + + + + + + + + + - + + - + + @@ -86,11 +121,16 @@ Content="Retry setup or download" Click="OnRetrySetup" HorizontalAlignment="Left" AutomationProperties.AutomationId="LocalAiRetrySetup"/> - + + - + + @@ -113,7 +153,8 @@ AutomationProperties.AutomationId="LocalAiOpenChat"/> - + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml.cs index a96c94bbc..363f46412 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/LocalAiPage.xaml.cs @@ -1,4 +1,5 @@ using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using OpenClaw.Shared; @@ -87,6 +88,13 @@ private void RefreshFromViewModel() ActionErrorText.Visibility = string.IsNullOrWhiteSpace(ActionErrorText.Text) ? Visibility.Collapsed : Visibility.Visible; EngineBusyIndicator.IsActive = _viewModel.IsBusy; EngineBusyIndicator.Visibility = _viewModel.IsBusy ? Visibility.Visible : Visibility.Collapsed; + LocalAiUnavailableInfoBar.Visibility = + _viewModel.IsAvailabilityKnown && !_viewModel.IsLocalAiAvailable + ? Visibility.Visible + : Visibility.Collapsed; + SetOptionAvailability(LocalAiEngineOption, _viewModel.AreOptionsEnabled); + SetOptionAvailability(LocalAiModelOption, _viewModel.AreOptionsEnabled); + SetOptionAvailability(LocalAiGatewayOption, _viewModel.AreOptionsEnabled); StartButton.IsEnabled = _viewModel.CanStart; StopButton.IsEnabled = _viewModel.CanStop; RestartButton.IsEnabled = _viewModel.CanRestart; @@ -104,6 +112,21 @@ private void RefreshFromViewModel() private void OnRetrySetup(object sender, RoutedEventArgs e) => _viewModel?.RetrySetup(); private void OnRepairConnection(object sender, RoutedEventArgs e) => _viewModel?.RepairConnection(); private void OnOpenChat(object sender, RoutedEventArgs e) => _viewModel?.OpenChat(); + private void LocalAiUnavailableDetails_Click(object sender, RoutedEventArgs e) + { + LocalAiUnavailableReasonText.Text = _viewModel?.LocalAiUnavailableReason ?? string.Empty; + LocalAiUnavailableDetailsTip.IsOpen = !LocalAiUnavailableDetailsTip.IsOpen; + } + + private static void SetOptionAvailability(Control option, bool isAvailable) + { + option.IsEnabled = isAvailable; + option.Opacity = isAvailable ? 1 : 0.55; + AutomationProperties.SetHelpText( + option, + isAvailable ? string.Empty : "Unavailable because Local AI is not available on this device."); + } + private static void RunAction(Func> action, string source) => AsyncEventHandlerGuard.Run(action, new AppLogger(), source); private static Brush ResolveBrush(string key) => (Brush)Application.Current.Resources[key]; diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs index 24e656243..2d8a61746 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared.ExecApprovals; +using OpenClaw.Shared.Inference; using Microsoft.Extensions.DependencyInjection; using OpenClawTray.Chat; using OpenClawTray.Services; @@ -42,6 +43,7 @@ public static IServiceCollection AddOpenClawTrayCore(this IServiceCollection ser // its Saved-event subscription during shutdown. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Container-owned navigation lifetime manager (disposed with the root provider). services.AddSingleton(); diff --git a/src/OpenClaw.Tray.WinUI/Presentation/LocalAiPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/LocalAiPageViewModel.cs index 0b8d7980a..e65289383 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/LocalAiPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/LocalAiPageViewModel.cs @@ -1,5 +1,6 @@ using OpenClaw.Connection; using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; using OpenClaw.Shared.Inference.Catalog; using OpenClawTray.Services; using System.ComponentModel; @@ -18,24 +19,31 @@ internal sealed class LocalAiPageViewModel : INavigationAware, IDisposable, INot private readonly IPermissionsPageRuntimeSource _gatewaySource; private readonly IAppCommands _appCommands; private readonly IUiDispatcher _dispatcher; + private readonly IHostHardwareProbe _hardwareProbe; private LocalAiRuntimeSnapshot _runtimeSnapshot; private GatewayConnectionSnapshot _gatewaySnapshot; private CancellationTokenSource? _refreshCancellation; + private CancellationTokenSource? _availabilityCancellation; private bool _subscribed; private bool _disposed; private bool _isBusy; private string? _actionError; + private bool _isAvailabilityKnown; + private bool _isLocalAiAvailable; + private string? _localAiUnavailableReason; public LocalAiPageViewModel( ILocalAiRuntime runtime, IPermissionsPageRuntimeSource gatewaySource, IAppCommands appCommands, - IUiDispatcher dispatcher) + IUiDispatcher dispatcher, + IHostHardwareProbe hardwareProbe) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _gatewaySource = gatewaySource ?? throw new ArgumentNullException(nameof(gatewaySource)); _appCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _hardwareProbe = hardwareProbe ?? throw new ArgumentNullException(nameof(hardwareProbe)); _runtimeSnapshot = runtime.Snapshot; _gatewaySnapshot = gatewaySource.Current.ConnectionSnapshot; } @@ -110,18 +118,22 @@ public LocalAiPageViewModel( public string? GatewayDetail => _gatewaySnapshot.GatewayName ?? _gatewaySnapshot.GatewayUrl; public string? ActionError => _actionError; public bool IsBusy => _isBusy; - public bool CanStart => !IsBusy && HasManagedInstall && + public bool IsAvailabilityKnown => _isAvailabilityKnown; + public bool IsLocalAiAvailable => _isAvailabilityKnown && _isLocalAiAvailable; + public bool AreOptionsEnabled => !_isAvailabilityKnown || _isLocalAiAvailable; + public string? LocalAiUnavailableReason => _localAiUnavailableReason; + public bool CanStart => AreOptionsEnabled && !IsBusy && HasManagedInstall && _runtimeSnapshot.State is LocalAiRuntimeState.Stopped or LocalAiRuntimeState.Failed; - public bool CanStop => !IsBusy && _runtimeSnapshot.Ownership == LocalAiOwnership.CompanionManaged && + public bool CanStop => AreOptionsEnabled && !IsBusy && _runtimeSnapshot.Ownership == LocalAiOwnership.CompanionManaged && _runtimeSnapshot.State is LocalAiRuntimeState.Starting or LocalAiRuntimeState.Healthy; - public bool CanRestart => !IsBusy && _runtimeSnapshot.Ownership == LocalAiOwnership.CompanionManaged && + public bool CanRestart => AreOptionsEnabled && !IsBusy && _runtimeSnapshot.Ownership == LocalAiOwnership.CompanionManaged && _runtimeSnapshot.State == LocalAiRuntimeState.Healthy; - public bool CanOpenLogs => !IsBusy && HasManagedInstall; - public bool CanRetrySetup => !IsBusy && ModelState is + public bool CanOpenLogs => AreOptionsEnabled && !IsBusy && HasManagedInstall; + public bool CanRetrySetup => AreOptionsEnabled && !IsBusy && ModelState is LocalAiModelPresentationState.NotInstalled or LocalAiModelPresentationState.Unknown; - public bool CanRepairConnection => !IsBusy && GatewayState is not + public bool CanRepairConnection => AreOptionsEnabled && !IsBusy && GatewayState is not (LocalAiGatewayPresentationState.Connected or LocalAiGatewayPresentationState.Connecting); - public bool CanOpenChat => !IsBusy && + public bool CanOpenChat => AreOptionsEnabled && !IsBusy && GatewayState == LocalAiGatewayPresentationState.Connected && _runtimeSnapshot.State == LocalAiRuntimeState.Healthy && ModelState is LocalAiModelPresentationState.Verified or LocalAiModelPresentationState.Loaded; @@ -144,11 +156,13 @@ public void Activate(object? parameter) ApplyRuntimeSnapshot(_runtime.Snapshot); ApplyGatewaySnapshot(_gatewaySource.Current.ConnectionSnapshot); StartRuntimeRefresh(); + StartAvailabilityRefresh(); } public void Deactivate() { CancelRuntimeRefresh(); + CancelAvailabilityRefresh(); if (_subscribed) { _runtime.StateChanged -= OnRuntimeStateChanged; @@ -189,6 +203,67 @@ private void CancelRuntimeRefresh() cancellation?.Cancel(); } + private void StartAvailabilityRefresh() + { + CancelAvailabilityRefresh(); + var cancellation = new CancellationTokenSource(); + _availabilityCancellation = cancellation; + _ = RefreshAvailabilityAsync(cancellation); + } + + private void CancelAvailabilityRefresh() + { + CancellationTokenSource? cancellation = _availabilityCancellation; + _availabilityCancellation = null; + cancellation?.Cancel(); + } + + private async Task RefreshAvailabilityAsync(CancellationTokenSource cancellation) + { + try + { + HostHardwareInfo hardware = await Task.Run( + _hardwareProbe.Probe, + cancellation.Token).ConfigureAwait(false); + LocalInferenceEligibilityResult eligibility = LocalInferenceEligibility.Evaluate( + hardware, + _runtimeSnapshot.ModelId); + bool isAvailable = eligibility.CanInstall; + string? unavailableReason = isAvailable + ? null + : LocalInferenceEligibilityDiagnostics.DescribeUnavailable(eligibility); + ApplyOnUiThread(() => + { + _isAvailabilityKnown = true; + _isLocalAiAvailable = isAvailable; + _localAiUnavailableReason = unavailableReason; + OnPropertyChanged(null); + }); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + catch + { + const string unavailableReason = + "OpenClaw could not read the NVIDIA GPU, driver, CUDA, or memory information. " + + "Check the NVIDIA driver installation and try setup again."; + ApplyOnUiThread(() => + { + _isAvailabilityKnown = true; + _isLocalAiAvailable = false; + _localAiUnavailableReason = unavailableReason; + OnPropertyChanged(null); + }); + } + finally + { + if (ReferenceEquals(_availabilityCancellation, cancellation)) + _availabilityCancellation = null; + cancellation.Dispose(); + } + } + private async Task RefreshRuntimeSnapshotAsync(CancellationTokenSource cancellation) { try diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index eba280464..4c299e397 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7042,6 +7042,9 @@ Make sure the gateway is running. Local AI Local AI Run the verified native llama-server privately on this computer. Models load only when requested. + Local AI is not available + See why + Why Local AI is unavailable llama-server Version Endpoint diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 6ad28bcf7..6f5f3242c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6978,6 +6978,9 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c IA locale IA locale Exécutez le llama-server natif vérifié en privé sur cet ordinateur. Les modèles ne sont chargés qu'à la demande. + L'IA locale n'est pas disponible + Voir pourquoi + Pourquoi l'IA locale n'est pas disponible llama-server Version du moteur Point de terminaison diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index a7763f031..03e267a4e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6979,6 +6979,9 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil Lokale AI Lokale AI Voer de geverifieerde native llama-server privé uit op deze computer. Modellen worden alleen op aanvraag geladen. + Lokale AI is niet beschikbaar + Bekijk waarom + Waarom lokale AI niet beschikbaar is llama-server Versie Eindpunt diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 195902787..522c46afb 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6978,6 +6978,9 @@ 本地 AI 本地 AI 在此计算机上私密运行经过验证的原生 llama-server。模型仅在收到请求时加载。 + 本地 AI 不可用 + 查看原因 + 本地 AI 不可用的原因 llama-server 版本 终结点 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index c1fb6f93c..795ec24a7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6978,6 +6978,9 @@ 本機 AI 本機 AI 在這部電腦上私密執行經過驗證的原生 llama-server。模型只會在收到要求時載入。 + 本機 AI 無法使用 + 查看原因 + 本機 AI 無法使用的原因 llama-server 版本 端點 diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index da3eea845..dcbe1ab45 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -1361,12 +1361,14 @@ public void CapabilitiesPage_AggregatesHardwareAndWslLocalAiDiagnosis() Assert.Contains("Why Local AI is unavailable", source); Assert.Contains("LocalAiInstallReviewCard.Visibility = Visibility.Visible", ExtractMethod(source, "ShowLocalAiUnavailable")); Assert.Contains("LocalAiAvailabilityReasons.Build", source); - Assert.Contains("One or more Local AI requirements are unavailable.", xaml); - Assert.Matches( - new Regex( - "]*>\\s*" + - ""), - xaml); + Assert.Contains("This PC does not meet one or more Local AI requirements.", xaml); + Assert.Contains("Title=\"Local AI is not available\"", xaml); + Assert.Contains("SetLocalAiOptionAvailability(isAvailable: false)", ExtractMethod(source, "ShowLocalAiUnavailable")); + Assert.Contains("Message=\"This PC does not meet one or more Local AI requirements.\"", xaml); + Assert.Contains("", xaml); + Assert.True( + xaml.IndexOf("x:Name=\"LocalAiUnavailablePanel\"", StringComparison.Ordinal) < + xaml.IndexOf("x:Name=\"LocalAiInstallReviewCard\"", StringComparison.Ordinal)); } [Fact] @@ -1374,11 +1376,12 @@ public void CapabilitiesPage_FiltersModelsBySelectedGpuCapacityAndShowsMemoryEvi { var root = TestRepositoryPaths.GetRepositoryRoot(); var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + var diagnostics = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Shared", "Inference", "Catalog", "LocalInferenceEligibilityDiagnostics.cs")); Assert.Contains("LocalInferenceEligibility.GetRequiredMemoryBytes(model) <= capacityBytes", source); - Assert.Contains("eligibility.RequiredTotalMemoryBytes", source); - Assert.Contains("eligibility.DetectedTotalMemoryBytes", source); - Assert.Contains("model weights, KV cache, and runtime workspace", source); + Assert.Contains("eligibility.RequiredTotalMemoryBytes", diagnostics); + Assert.Contains("eligibility.DetectedTotalMemoryBytes", diagnostics); + Assert.Contains("model weights, KV cache, and runtime workspace", diagnostics); Assert.DoesNotContain("2 GiB runtime margin", source); Assert.DoesNotContain("HardwareProfile", source); Assert.DoesNotContain("RTX PRO 6000", source); diff --git a/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs new file mode 100644 index 000000000..4e8b0ba99 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalAiSetupUxContractTests.cs @@ -0,0 +1,130 @@ +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public sealed class LocalAiSetupUxContractTests +{ + [Fact] + public void WelcomePage_ShowsLocalAiAsIconBadgeBesideRecommended() + { + string root = TestRepositoryPaths.GetRepositoryRoot(); + string xaml = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.SetupEngine.UI", + "Pages", + "WelcomePage.xaml")); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.SetupEngine.UI", + "Pages", + "WelcomePage.xaml.cs")); + string badge = ExtractElement(xaml, "LocalAiAvailabilityBadge", ""); + + Assert.Contains("WelcomeLocalAiAvailable", badge); + Assert.Contains("Glyph=\"\"", badge); + Assert.Contains("Local AI available", badge); + Assert.Contains("AutomationProperties.AccessibilityView=\"Raw\"", badge); + AssertInOrder(xaml, "Text=\"Recommended\"", "x:Name=\"LocalAiAvailabilityBadge\""); + Assert.DoesNotContain("LocalAiAvailabilityPanel", xaml); + Assert.DoesNotContain("LocalAiAvailabilityText", xaml); + Assert.DoesNotContain("detected. Install a local gateway", source); + Assert.Contains("AutomationProperties.SetName(", source); + } + + [Fact] + public void CapabilitiesReview_SeparatesReasonActionFromDisabledOptions() + { + string root = TestRepositoryPaths.GetRepositoryRoot(); + string xaml = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.SetupEngine.UI", + "Pages", + "CapabilitiesPage.xaml")); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.SetupEngine.UI", + "Pages", + "CapabilitiesPage.xaml.cs")); + string infoBar = ExtractElement(xaml, "LocalAiUnavailablePanel", ""); + + Assert.Contains("Title=\"Local AI is not available\"", xaml); + Assert.Contains("Severity=\"Informational\"", xaml); + Assert.Contains("AutomationProperties.LiveSetting=\"Polite\"", xaml); + Assert.Contains("Message=\"This PC does not meet one or more Local AI requirements.\"", infoBar); + Assert.Contains("", infoBar); + Assert.DoesNotContain("= 0, $"Could not find {elementName}."); + int end = source.IndexOf(closingTag, start, StringComparison.Ordinal); + Assert.True(end >= 0, $"Could not find the end of {elementName}."); + return source[start..(end + closingTag.Length)]; + } + + private static void AssertInOrder(string source, params string[] values) + { + int previous = -1; + foreach (string value in values) + { + int current = source.IndexOf(value, previous + 1, StringComparison.Ordinal); + Assert.True(current > previous, $"Expected '{value}' after the previous value."); + previous = current; + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs index f83e5f57e..c9f0a4f49 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using OpenClaw.Shared; using OpenClaw.Shared.ExecApprovals; +using OpenClaw.Shared.Inference; using OpenClawTray.Chat; using OpenClawTray.Presentation; using OpenClawTray.Services; @@ -68,6 +69,10 @@ public void AppOwnedSingletons_ResolveToTheProvidedInstances() Assert.Same(settings, provider.GetRequiredService()); Assert.Same(execApprovalsStore, provider.GetRequiredService()); Assert.Same(runtimeHost, provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + Assert.Same( + provider.GetRequiredService(), + provider.GetRequiredService()); Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/LocalAiPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/LocalAiPageViewModelTests.cs new file mode 100644 index 000000000..ceb45ebfc --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Presentation/LocalAiPageViewModelTests.cs @@ -0,0 +1,174 @@ +using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; +using OpenClaw.Shared.Inference; +using OpenClaw.Shared.Inference.Catalog; +using OpenClawTray.Presentation; +using System.Runtime.InteropServices; + +namespace OpenClaw.Tray.Tests.Presentation; + +public sealed class LocalAiPageViewModelTests +{ + [Fact] + public async Task UnsupportedHardware_DisablesEveryOptionAndCommand() + { + var runtime = new FakeLocalAiRuntime(CreateInstalledSnapshot()); + var runtimeHost = new FakePermissionsPageRuntimeHost + { + ConnectionSnapshot = GatewayConnectionSnapshot.Idle with + { + OperatorState = RoleConnectionState.Idle, + }, + }; + using var gatewaySource = new PermissionsPageRuntimeSource(runtimeHost); + var commands = new FakeAppCommands(); + using var viewModel = new LocalAiPageViewModel( + runtime, + gatewaySource, + commands, + new RecordingUiDispatcher(), + new FixedHardwareProbe(HostHardwareInfo.Unknown)); + + Assert.False(viewModel.IsAvailabilityKnown); + Assert.True(viewModel.AreOptionsEnabled); + + await ActivateAndWaitForAvailabilityAsync(viewModel); + + Assert.True(viewModel.IsAvailabilityKnown); + Assert.False(viewModel.IsLocalAiAvailable); + Assert.False(viewModel.AreOptionsEnabled); + Assert.Contains("NVIDIA GPU", viewModel.LocalAiUnavailableReason); + Assert.False(viewModel.CanStart); + Assert.False(viewModel.CanStop); + Assert.False(viewModel.CanRestart); + Assert.False(viewModel.CanOpenLogs); + Assert.False(viewModel.CanRetrySetup); + Assert.False(viewModel.CanRepairConnection); + Assert.False(viewModel.CanOpenChat); + Assert.False(viewModel.OpenLogs()); + Assert.False(viewModel.RetrySetup()); + Assert.False(viewModel.RepairConnection()); + Assert.False(viewModel.OpenChat()); + Assert.Equal(0, commands.OpenLocalAiLogsCount); + Assert.Equal(0, commands.ShowOnboardingCount); + Assert.Equal(0, commands.ReconnectCount); + Assert.Equal(0, commands.ShowChatCount); + } + + [Fact] + public async Task QualifiedHardware_EnablesApplicableOptionsAndRoutesActions() + { + var runtime = new FakeLocalAiRuntime(CreateInstalledSnapshot()); + var runtimeHost = new FakePermissionsPageRuntimeHost + { + ConnectionSnapshot = GatewayConnectionSnapshot.Idle with + { + OperatorState = RoleConnectionState.Connected, + }, + }; + using var gatewaySource = new PermissionsPageRuntimeSource(runtimeHost); + var commands = new FakeAppCommands(); + using var viewModel = new LocalAiPageViewModel( + runtime, + gatewaySource, + commands, + new RecordingUiDispatcher(), + new FixedHardwareProbe(CreateQualifiedHardware())); + + await ActivateAndWaitForAvailabilityAsync(viewModel); + + Assert.True(viewModel.IsLocalAiAvailable); + Assert.True(viewModel.AreOptionsEnabled); + Assert.Null(viewModel.LocalAiUnavailableReason); + Assert.True(viewModel.CanStop); + Assert.True(viewModel.CanRestart); + Assert.True(viewModel.CanOpenLogs); + Assert.True(viewModel.CanOpenChat); + Assert.True(viewModel.OpenLogs()); + Assert.True(viewModel.OpenChat()); + Assert.Equal(1, commands.OpenLocalAiLogsCount); + Assert.Equal(1, commands.ShowChatCount); + } + + private static async Task ActivateAndWaitForAvailabilityAsync(LocalAiPageViewModel viewModel) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + viewModel.PropertyChanged += (_, _) => + { + if (viewModel.IsAvailabilityKnown) + completion.TrySetResult(); + }; + + viewModel.Activate(null); + if (!viewModel.IsAvailabilityKnown) + await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static HostHardwareInfo CreateQualifiedHardware() => + new( + Architecture.X64, + TotalPhysicalMemoryBytes: 256_000_000_000, + AvailablePhysicalMemoryBytes: 128_000_000_000, + Gpus: + [ + new GpuInfo( + GpuVendor.Nvidia, + "NVIDIA Test GPU", + GpuVisibleMemoryBytes: 128_000_000_000, + FreeGpuVisibleMemoryBytes: 128_000_000_000, + DriverVersion: "620.0", + CudaMajorVersion: 13, + StableId: "GPU-test"), + ], + VulkanAvailable: false); + + private static LocalAiRuntimeSnapshot CreateInstalledSnapshot() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + string modelId = LocalModelCatalog.Models[0].Id; + return new LocalAiRuntimeSnapshot( + LocalAiRuntimeState.Healthy, + LocalAiOwnership.CompanionManaged, + new Uri("http://127.0.0.1:18080"), + "test", + modelId, + new LocalAiModelEvidence( + LocalAiModelAvailabilityState.Verified, + now, + new string('0', 64), + sizeBytes: 1), + ProcessId: 1234, + ProcessStartedAtUtc: now, + Detail: null, + UpdatedAtUtc: now); + } + + private sealed class FixedHardwareProbe(HostHardwareInfo hardware) : IHostHardwareProbe + { + public HostHardwareInfo Probe() => hardware; + } + + private sealed class FakeLocalAiRuntime(LocalAiRuntimeSnapshot snapshot) : ILocalAiRuntime + { + public LocalAiRuntimeSnapshot Snapshot { get; private set; } = snapshot; + public event EventHandler? StateChanged + { + add { } + remove { } + } + + public Task EnsureStartedAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public Task StopAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public Task RestartAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public Task RefreshAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Snapshot); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs index b11f0055a..c54142325 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs @@ -64,17 +64,22 @@ internal sealed class FakeAppCommands : IAppCommands, IDisposable public List Navigations { get; } = new(); public IReadOnlyList OperationLog => _operationLog; public bool Disposed { get; private set; } + public int ReconnectCount { get; private set; } + public int ShowChatCount { get; private set; } + public int ShowOnboardingCount { get; private set; } + public int OpenLocalAiLogsCount { get; private set; } public void ClearOperationLog() => _operationLog.Clear(); public void OpenDashboard(string? path = null) { } public void Navigate(string pageTag) => Navigations.Add(pageTag); - public void Reconnect() { } + public void Reconnect() => ReconnectCount++; public void Disconnect() { } public void ShowVoiceOverlay() { } - public void ShowChat() { } + public void ShowChat() => ShowChatCount++; public void CheckForUpdates() { } - public void ShowOnboarding() { } + public void ShowOnboarding() => ShowOnboardingCount++; + public void OpenLocalAiLogs() => OpenLocalAiLogsCount++; public void ShowGatewayWizard() { } public void ShowConnectionStatus() { } public void NotifySettingsSaved()