diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml index e98a7f05c..61e77de3d 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml @@ -16,6 +16,76 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" Margin="0,0,0,8"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (App)Microsoft.UI.Xaml.Application.Current!; private VoiceService? _voiceService; + private AssistantBridgeService? _assistantBridgeService; + private CancellationTokenSource? _assistantRequestCts; + private int _assistantOperationVersion; private bool _suppressEvents = true; // suppress until Initialize/LoadSettings runs // Per-asset CTS so a Piper download doesn't cancel an in-flight Whisper // download (and vice versa). Each download type owns its own token. @@ -34,9 +37,12 @@ public VoiceSettingsPage() { UpdateModelStatus(); UpdatePiperVoiceState(); + _ = RefreshAssistantAsync(); }; Unloaded += async (_, _) => { + CancelAssistantRequest(); + if (App.Current is App app) app.SpeakerMuteChanged -= OnAppSpeakerMuteChanged; @@ -52,6 +58,7 @@ public VoiceSettingsPage() public void Initialize(VoiceService? voiceService) { _voiceService = voiceService; + _assistantBridgeService ??= new AssistantBridgeService(new AppLogger()); if (App.Current is App app) { app.SpeakerMuteChanged -= OnAppSpeakerMuteChanged; @@ -64,6 +71,248 @@ public void Initialize(VoiceService? voiceService) PiperPreviewLabel.Text = L("VoiceSettingsPage_PiperPreviewButtonContent"); PreviewVoiceLabel.Text = L("VoiceSettingsPage_PreviewVoiceButtonContent"); LoadSettings(); + _ = RefreshAssistantAsync(); + } + + private void OnAssistantRefreshClick(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + RefreshAssistantAsync, + new OpenClawTray.AppLogger(), + nameof(OnAssistantRefreshClick)); + + private void OnAssistantStartClick(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + OnAssistantStartClickAsync, + new OpenClawTray.AppLogger(), + nameof(OnAssistantStartClick)); + + private async Task OnAssistantStartClickAsync() + { + var bridge = EnsureAssistantBridge(); + var busyVersion = BeginAssistantOperation(); + AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStarting"); + AssistantDetailText.Text = L("VoiceSettingsPage_AssistantStartingDetail"); + try + { + var result = await bridge.StartListenServiceAsync(NewAssistantRequestToken()); + if (!result.Success) + { + AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStartFailed"); + AssistantDetailText.Text = result.ErrorMessage; + return; + } + + await RefreshAssistantAsync(); + } + catch (OperationCanceledException) + { + } + finally + { + EndAssistantOperation(busyVersion); + } + } + + private void OnAssistantStopClick(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + OnAssistantStopClickAsync, + new OpenClawTray.AppLogger(), + nameof(OnAssistantStopClick)); + + private async Task OnAssistantStopClickAsync() + { + var bridge = EnsureAssistantBridge(); + var busyVersion = BeginAssistantOperation(); + AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStopping"); + AssistantDetailText.Text = ""; + try + { + var result = await bridge.StopListenServiceAsync(NewAssistantRequestToken()); + if (!result.Success) + { + AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStopFailed"); + AssistantDetailText.Text = result.ErrorMessage; + return; + } + + await RefreshAssistantAsync(); + } + catch (OperationCanceledException) + { + } + finally + { + EndAssistantOperation(busyVersion); + } + } + + private async Task RefreshAssistantAsync() + { + var bridge = EnsureAssistantBridge(); + var busyVersion = BeginAssistantOperation(); + try + { + var snapshot = await bridge.GetStatusAsync(NewAssistantRequestToken()); + RenderAssistantSnapshot(snapshot); + } + catch (OperationCanceledException) + { + } + finally + { + EndAssistantOperation(busyVersion); + } + } + + private AssistantBridgeService EnsureAssistantBridge() => + _assistantBridgeService ??= new AssistantBridgeService(new AppLogger()); + + private CancellationToken NewAssistantRequestToken() + { + CancelAssistantRequest(); + _assistantRequestCts = new CancellationTokenSource(); + return _assistantRequestCts.Token; + } + + private void CancelAssistantRequest() + { + try { _assistantRequestCts?.Cancel(); } + catch (Exception ex) { Logger.Debug($"VoiceSettingsPage: assistant request cancel failed: {ex.Message}"); } + _assistantRequestCts?.Dispose(); + _assistantRequestCts = null; + } + + private void SetAssistantBusy(bool busy) + { + AssistantRefreshButton.IsEnabled = !busy; + AssistantStartButton.IsEnabled = !busy; + AssistantStopButton.IsEnabled = !busy; + } + + private int BeginAssistantOperation() + { + var version = Interlocked.Increment(ref _assistantOperationVersion); + SetAssistantBusy(true); + return version; + } + + private void EndAssistantOperation(int version) + { + if (Volatile.Read(ref _assistantOperationVersion) == version) + SetAssistantBusy(false); + } + + private void RenderAssistantSnapshot(AssistantBridgeSnapshot snapshot) + { + if (!snapshot.IsAvailable) + { + AssistantStatusText.Text = L("VoiceSettingsPage_AssistantBridgeUnavailable"); + AssistantDetailText.Text = snapshot.ErrorMessage; + AssistantLastRefreshText.Text = ""; + AssistantTurnsPanel.Children.Clear(); + AssistantTurnsPanel.Children.Add(new TextBlock + { + Text = L("VoiceSettingsPage_AssistantNoTurnsLoaded"), + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + TextWrapping = TextWrapping.Wrap + }); + return; + } + + var listen = snapshot.ListenService; + var unknown = L("VoiceSettingsPage_AssistantUnknown"); + var status = string.IsNullOrWhiteSpace(listen.Status) ? unknown : listen.Status; + AssistantStatusText.Text = listen.IsRunning + ? Lf("VoiceSettingsPage_AssistantListeningFormat", listen.Pid?.ToString(CultureInfo.CurrentCulture) ?? unknown) + : listen.IsStopped + ? L("VoiceSettingsPage_AssistantStopped") + : Lf("VoiceSettingsPage_AssistantStatusFormat", status); + + var details = new[] + { + string.IsNullOrWhiteSpace(listen.Transcriber) ? "" : Lf("VoiceSettingsPage_AssistantTranscriberFormat", listen.Transcriber), + string.IsNullOrWhiteSpace(snapshot.PreferredInputDevice) ? "" : Lf("VoiceSettingsPage_AssistantMicFormat", snapshot.PreferredInputDevice), + string.IsNullOrWhiteSpace(snapshot.PreferredOutputDevice) ? "" : Lf("VoiceSettingsPage_AssistantSpeakerFormat", snapshot.PreferredOutputDevice), + Lf("VoiceSettingsPage_AssistantCloudRoutingFormat", listen.AllowCloud ? L("VoiceSettingsPage_AssistantOn") : L("VoiceSettingsPage_AssistantOff")), + Lf("VoiceSettingsPage_AssistantSpeechOutputFormat", listen.SpeakAloud ? L("VoiceSettingsPage_AssistantOn") : L("VoiceSettingsPage_AssistantOff")) + }.Where(s => !string.IsNullOrWhiteSpace(s)); + AssistantDetailText.Text = string.Join(" | ", details); + AssistantLastRefreshText.Text = string.IsNullOrWhiteSpace(snapshot.GeneratedAt) + ? "" + : Lf("VoiceSettingsPage_AssistantLastBridgeUpdateFormat", snapshot.GeneratedAt); + + AssistantTurnsPanel.Children.Clear(); + if (snapshot.RecentTurns.Count == 0) + { + AssistantTurnsPanel.Children.Add(new TextBlock + { + Text = L("VoiceSettingsPage_AssistantNoConversationTurns"), + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + TextWrapping = TextWrapping.Wrap + }); + return; + } + + foreach (var turn in snapshot.RecentTurns.Take(5)) + AssistantTurnsPanel.Children.Add(CreateAssistantTurnView(turn)); + } + + private static Border CreateAssistantTurnView(AssistantTurnSnapshot turn) + { + var panel = new StackPanel { Spacing = 6 }; + var source = string.IsNullOrWhiteSpace(turn.Source) ? L("VoiceSettingsPage_AssistantSourceDefault") : turn.Source; + var stage = string.IsNullOrWhiteSpace(turn.Stage) ? L("VoiceSettingsPage_AssistantUnknown") : turn.Stage; + var model = string.IsNullOrWhiteSpace(turn.Provider) + ? turn.ModelProfile + : $"{turn.Provider} {turn.ModelProfile}".Trim(); + var metadata = turn.TotalMs is int ms + ? Lf("VoiceSettingsPage_AssistantTurnMetadataWithLatencyFormat", source, stage, ms.ToString(CultureInfo.CurrentCulture)) + : Lf("VoiceSettingsPage_AssistantTurnMetadataFormat", source, stage); + panel.Children.Add(new TextBlock + { + Text = metadata, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + TextWrapping = TextWrapping.Wrap + }); + panel.Children.Add(new TextBlock + { + Text = Lf("VoiceSettingsPage_AssistantUserTurnFormat", TrimForDisplay(turn.InputText)), + TextWrapping = TextWrapping.Wrap + }); + panel.Children.Add(new TextBlock + { + Text = Lf("VoiceSettingsPage_AssistantResponseTurnFormat", TrimForDisplay(turn.ResponseText)), + TextWrapping = TextWrapping.Wrap + }); + if (!string.IsNullOrWhiteSpace(model)) + { + panel.Children.Add(new TextBlock + { + Text = model, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + TextWrapping = TextWrapping.Wrap + }); + } + + return new Border + { + Child = panel, + Background = (Brush)Application.Current.Resources["CardBackgroundFillColorSecondaryBrush"], + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12) + }; + } + + private static string TrimForDisplay(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return L("VoiceSettingsPage_AssistantEmptyValue"); + value = value.Trim(); + return value.Length <= 220 ? value : value[..217] + "..."; } private void OnAppSpeakerMuteChanged(bool muted) diff --git a/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs b/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs new file mode 100644 index 000000000..3f6572a1f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs @@ -0,0 +1,449 @@ +using OpenClaw.Shared; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Services; + +internal sealed class AssistantBridgeService +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20); + private readonly IOpenClawLogger _logger; + private readonly string _userId; + private readonly OpenClawCliLauncher? _launcher; + private readonly TimeSpan _timeout; + + public AssistantBridgeService(IOpenClawLogger logger, string userId = "owner") + : this(logger, userId, ResolveOpenClawCli) + { + } + + internal AssistantBridgeService( + IOpenClawLogger logger, + string userId, + Func launcherResolver, + TimeSpan? commandTimeout = null) + { + _logger = logger; + _userId = string.IsNullOrWhiteSpace(userId) ? "owner" : userId; + _launcher = launcherResolver(); + _timeout = commandTimeout ?? DefaultTimeout; + + var overrideRoot = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT"); + if (!string.IsNullOrWhiteSpace(overrideRoot)) + { + if (TryNormalizeBackendRoot(overrideRoot, out var normalizedOverrideRoot)) + { + if (_launcher != null && + string.Equals(_launcher.WorkingDirectory, normalizedOverrideRoot, StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"Assistant bridge using OPENCLAW_BACKEND_ROOT launcher at '{_launcher.WorkingDirectory}'."); + } + } + else + { + _logger.Warn("Assistant bridge ignored OPENCLAW_BACKEND_ROOT because it is not a fully qualified local checkout under a trusted parent."); + } + } + } + + public async Task GetStatusAsync(CancellationToken cancellationToken = default) + { + var result = await RunOpenClawAsync(["dashboard", "bridge", "status", "--user", _userId, "--json"], cancellationToken); + if (!result.Success) + return AssistantBridgeSnapshot.Unavailable(result.ErrorMessage); + + try + { + return ParseStatus(result.Stdout); + } + catch (JsonException ex) + { + _logger.Warn($"Assistant bridge status JSON could not be parsed: {ex.Message}"); + return AssistantBridgeSnapshot.Unavailable("OpenClaw returned status JSON the Companion could not read."); + } + } + + public Task StartListenServiceAsync(CancellationToken cancellationToken = default) => + RunCommandAsync( + ["assistant", "listen-service", "start", "--user", _userId, "--store-turn", "--json"], + cancellationToken); + + public Task StopListenServiceAsync(CancellationToken cancellationToken = default) => + RunCommandAsync( + ["assistant", "listen-service", "stop", "--user", _userId, "--json"], + cancellationToken); + + private async Task RunCommandAsync(IReadOnlyList args, CancellationToken cancellationToken) + { + var result = await RunOpenClawAsync(args, cancellationToken); + return new AssistantCommandResult(result.Success, result.ErrorMessage); + } + + private async Task RunOpenClawAsync( + IReadOnlyList args, + CancellationToken cancellationToken) + { + var launcher = _launcher; + if (launcher == null) + return AssistantProcessResult.Failed(BuildBackendNotFoundMessage()); + + var psi = new ProcessStartInfo + { + FileName = launcher.ExecutablePath, + WorkingDirectory = launcher.WorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var arg in launcher.PrefixArgs) + psi.ArgumentList.Add(arg); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + try + { + using var process = new Process { StartInfo = psi }; + if (!process.Start()) + return AssistantProcessResult.Failed("Could not start the OpenClaw backend command."); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_timeout); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token); + var timedOut = false; + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + timedOut = true; + TryKillProcessTree(process); + } + catch (OperationCanceledException) + { + TryKillProcessTree(process); + throw; + } + + var stdout = await ReadProcessStreamAsync(stdoutTask).ConfigureAwait(false); + var stderr = await ReadProcessStreamAsync(stderrTask).ConfigureAwait(false); + if (timedOut) + return AssistantProcessResult.Failed("OpenClaw backend command timed out."); + + if (process.ExitCode != 0) + { + var detail = string.IsNullOrWhiteSpace(stderr) ? "OpenClaw backend command failed." : stderr.Trim(); + return AssistantProcessResult.Failed(detail); + } + + return new AssistantProcessResult(true, stdout, ""); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Warn($"Assistant bridge command failed to start: {ex.Message}"); + return AssistantProcessResult.Failed("OpenClaw backend command could not be started."); + } + } + + private static async Task ReadProcessStreamAsync(Task streamTask) + { + try + { + return await streamTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return ""; + } + catch (IOException) + { + return ""; + } + catch (ObjectDisposedException) + { + return ""; + } + } + + private static void TryKillProcessTree(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + catch (Win32Exception) + { + } + catch (NotSupportedException) + { + } + } + + internal static AssistantBridgeSnapshot ParseStatus(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var generatedAt = ReadString(root, "generated_at"); + var assistant = ReadObject(root, "assistant"); + var voice = ReadObject(root, "voice"); + + var listen = AssistantListenServiceSnapshot.Empty; + if (assistant.HasValue && assistant.Value.TryGetProperty("listen_service", out var listenElement)) + { + listen = new AssistantListenServiceSnapshot( + ReadString(listenElement, "status"), + ReadBool(listenElement, "configured"), + ReadNullableInt(listenElement, "pid"), + ReadBool(listenElement, "stop_requested"), + ReadBool(listenElement, "allow_cloud"), + ReadBool(listenElement, "speak_aloud"), + ReadString(listenElement, "transcriber"), + ReadString(listenElement, "input_mode"), + ReadString(listenElement, "log_file")); + } + + var turns = new List(); + if (assistant.HasValue && assistant.Value.TryGetProperty("recent_turns", out var recentTurns) && + recentTurns.ValueKind == JsonValueKind.Array) + { + foreach (var turn in recentTurns.EnumerateArray()) + { + turns.Add(new AssistantTurnSnapshot( + ReadString(turn, "created_at"), + ReadString(turn, "source"), + ReadString(turn, "input_text"), + ReadString(turn, "display_response_text"), + ReadString(turn, "provider"), + ReadString(turn, "model_profile"), + ReadString(turn, "stage"), + ReadNullableInt(turn, "total_ms"))); + } + } + + return new AssistantBridgeSnapshot( + true, + "", + generatedAt, + ReadString(root, "user_id"), + ReadString(voice, "preferred_input_device"), + ReadString(voice, "preferred_output_device"), + listen, + turns); + } + + internal static OpenClawCliLauncher? ResolveOpenClawCli() + { + foreach (var root in CandidateBackendRoots()) + { + if (!TryNormalizeBackendRoot(root, out var normalizedRoot)) + continue; + + var openclawExe = Path.Combine(normalizedRoot, ".venv", "Scripts", "openclaw.exe"); + if (File.Exists(openclawExe)) + return new OpenClawCliLauncher(openclawExe, normalizedRoot, []); + + var pythonExe = Path.Combine(normalizedRoot, ".venv", "Scripts", "python.exe"); + if (File.Exists(pythonExe)) + return new OpenClawCliLauncher(pythonExe, normalizedRoot, ["-m", "openclaw.cli"]); + } + + return null; + } + + internal static string BuildBackendNotFoundMessage() + { + var searchedRoots = CandidateBackendRoots() + .Select(root => TryNormalizeBackendRoot(root, out var normalizedRoot) ? normalizedRoot : root) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return "OpenClaw backend checkout was not found. Set OPENCLAW_BACKEND_ROOT to a fully qualified local checkout under D:\\Projects, %USERPROFILE%\\Projects, or %USERPROFILE%\\source\\repos. Searched: " + + string.Join("; ", searchedRoots) + + "."; + } + + internal static IReadOnlyList CandidateBackendRoots() + { + var candidates = new List(); + var overrideRoot = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT"); + if (!string.IsNullOrWhiteSpace(overrideRoot)) + candidates.Add(overrideRoot); + + candidates.Add(@"D:\Projects\OpenClaw"); + candidates.Add(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Projects", + "OpenClaw")); + candidates.Add(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "source", + "repos", + "OpenClaw")); + + return candidates; + } + + internal static bool TryNormalizeBackendRoot(string root, out string normalizedRoot) + { + normalizedRoot = ""; + if (string.IsNullOrWhiteSpace(root)) + return false; + + var expandedRoot = Environment.ExpandEnvironmentVariables(root.Trim()); + if (string.IsNullOrWhiteSpace(expandedRoot) || !Path.IsPathFullyQualified(expandedRoot)) + return false; + + try + { + normalizedRoot = Path.GetFullPath(expandedRoot); + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + + if (normalizedRoot.StartsWith(@"\\", StringComparison.Ordinal)) + return false; + + var candidateRoot = normalizedRoot; + return TrustedBackendParentRoots() + .Any(parent => IsSameOrUnderPath(candidateRoot, parent)); + } + + private static IEnumerable TrustedBackendParentRoots() + { + yield return @"D:\Projects"; + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + yield break; + + yield return Path.Combine(userProfile, "Projects"); + yield return Path.Combine(userProfile, "source", "repos"); + } + + private static bool IsSameOrUnderPath(string path, string parent) + { + var normalizedParent = Path.GetFullPath(parent) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedPath = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return string.Equals(normalizedPath, normalizedParent, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith(normalizedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static JsonElement? ReadObject(JsonElement? parent, string name) + { + if (parent is not { } element || element.ValueKind != JsonValueKind.Object) + return null; + return element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Object + ? value + : null; + } + + private static string ReadString(JsonElement? parent, string name) + { + if (parent is not { } element || element.ValueKind != JsonValueKind.Object) + return ""; + return element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "" + : ""; + } + + private static bool ReadBool(JsonElement parent, string name) => + parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + + private static int? ReadNullableInt(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var value)) + return null; + return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var parsed) + ? parsed + : null; + } + + private sealed record AssistantProcessResult(bool Success, string Stdout, string ErrorMessage) + { + public static AssistantProcessResult Failed(string errorMessage) => new(false, "", errorMessage); + } +} + +internal sealed record OpenClawCliLauncher( + string ExecutablePath, + string WorkingDirectory, + IReadOnlyList PrefixArgs); + +internal sealed record AssistantCommandResult(bool Success, string ErrorMessage); + +internal sealed record AssistantBridgeSnapshot( + bool IsAvailable, + string ErrorMessage, + string GeneratedAt, + string UserId, + string PreferredInputDevice, + string PreferredOutputDevice, + AssistantListenServiceSnapshot ListenService, + IReadOnlyList RecentTurns) +{ + public static AssistantBridgeSnapshot Unavailable(string errorMessage) => + new(false, errorMessage, "", "", "", "", AssistantListenServiceSnapshot.Empty, []); +} + +internal sealed record AssistantListenServiceSnapshot( + string Status, + bool Configured, + int? Pid, + bool StopRequested, + bool AllowCloud, + bool SpeakAloud, + string Transcriber, + string InputMode, + string LogFile) +{ + public static AssistantListenServiceSnapshot Empty { get; } = + new("", false, null, false, false, false, "", "", ""); + + public bool IsRunning => string.Equals(Status, "running", StringComparison.OrdinalIgnoreCase); + + public bool IsStopped => + string.Equals(Status, "stopped", StringComparison.OrdinalIgnoreCase) || + (StopRequested && string.Equals(Status, "stop-requested", StringComparison.OrdinalIgnoreCase)); +} + +internal sealed record AssistantTurnSnapshot( + string CreatedAt, + string Source, + string InputText, + string ResponseText, + string Provider, + string ModelProfile, + string Stage, + int? TotalMs); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 947a1ee93..00d5db710 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -2637,6 +2637,105 @@ On your gateway host (Mac/Linux), run: Configure speech-to-text and voice interaction settings. All speech processing runs locally on your device. + + Assistant + + + Always-listening assistant service and latest conversation turns. + + + Refresh assistant status + + + Start + + + Stop + + + Checking assistant status... + + + Recent turns + + + Starting assistant... + + + Starting always-listening mode with local assistant routing and turn storage. + + + Assistant did not start + + + Stopping assistant... + + + Assistant did not stop + + + Assistant bridge unavailable + + + No assistant turns loaded. + + + unknown + + + Assistant listening (PID {0}) + + + Assistant stopped + + + Assistant {0} + + + Transcriber: {0} + + + Mic: {0} + + + Speaker: {0} + + + Cloud routing: {0} + + + Speech output: {0} + + + on + + + off + + + Last bridge update: {0} + + + No conversation turns yet. + + + assistant + + + {0} | {1} + + + {0} | {1} | {2} ms + + + You: {0} + + + OpenClaw: {0} + + + (empty) + Speech-to-Text diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 144bb96b8..0988cbb46 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -2589,6 +2589,105 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Configurez la reconnaissance vocale et les paramètres d'interaction vocale. Tout le traitement vocal s'exécute localement sur votre appareil. + + Assistant vocal + + + Service d'assistant toujours à l'écoute et derniers tours de conversation. + + + Actualiser l'état de l'assistant + + + Démarrer + + + Arrêter + + + Vérification de l'état de l'assistant... + + + Tours récents + + + Démarrage de l'assistant... + + + Démarrage du mode toujours à l'écoute avec routage local de l'assistant et stockage des tours. + + + L'assistant n'a pas démarré + + + Arrêt de l'assistant... + + + L'assistant ne s'est pas arrêté + + + Pont assistant indisponible + + + Aucun tour d'assistant chargé. + + + inconnu + + + Assistant à l'écoute (PID {0}) + + + Assistant arrêté + + + État de l'assistant : {0} + + + Transcripteur : {0} + + + Micro : {0} + + + Haut-parleur : {0} + + + Routage cloud : {0} + + + Sortie vocale : {0} + + + activé + + + désactivé + + + Dernière mise à jour du pont : {0} + + + Aucun tour de conversation pour le moment. + + + l'assistant + + + {0} / {1} + + + {0} | {1} | {2} ms de latence + + + Vous : {0} + + + OpenClaw : {0} + + + (vide) + Reconnaissance vocale diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c298fbfb1..b6f0c2695 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -2590,6 +2590,105 @@ Voer op uw gateway-host (Mac/Linux) uit: Configureer spraak-naar-tekst en spraakinteractie-instellingen. Alle spraakverwerking draait lokaal op uw apparaat. + + Assistent + + + Altijd luisterende assistentservice en nieuwste gespreksbeurten. + + + Assistentstatus vernieuwen + + + Starten + + + Stoppen + + + Assistentstatus controleren... + + + Recente beurten + + + Assistent starten... + + + Altijd-luisterende modus starten met lokale assistentroutering en opslag van beurten. + + + Assistent is niet gestart + + + Assistent stoppen... + + + Assistent is niet gestopt + + + Assistentbrug niet beschikbaar + + + Geen assistentbeurten geladen. + + + onbekend + + + Assistent luistert (PID {0}) + + + Assistent gestopt + + + Assistent {0} + + + Transcriptie: {0} + + + Microfoon: {0} + + + Luidspreker: {0} + + + Cloudroutering: {0} + + + Spraakuitvoer: {0} + + + aan + + + uit + + + Laatste brugupdate: {0} + + + Nog geen gespreksbeurten. + + + assistent + + + {0} / {1} + + + {0} | {1} | {2} ms vertraging + + + Jij: {0} + + + OpenClaw-antwoord: {0} + + + (leeg) + Spraak-naar-tekst diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 2a66056b2..0e66266a3 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -2589,6 +2589,105 @@ 配置语音转文字和语音交互设置。所有语音处理均在本机本地运行。 + + 助手 + + + 始终监听的助手服务和最新对话轮次。 + + + 刷新助手状态 + + + 启动 + + + 停止 + + + 正在检查助手状态... + + + 最近轮次 + + + 正在启动助手... + + + 正在启动始终监听模式,并使用本地助手路由和轮次存储。 + + + 助手未启动 + + + 正在停止助手... + + + 助手未停止 + + + 助手桥不可用 + + + 未加载助手轮次。 + + + 未知 + + + 助手正在监听 (PID {0}) + + + 助手已停止 + + + 助手 {0} + + + 转录器:{0} + + + 麦克风:{0} + + + 扬声器:{0} + + + 云路由:{0} + + + 语音输出:{0} + + + 开启 + + + 关闭 + + + 上次桥更新:{0} + + + 还没有对话轮次。 + + + 助手 + + + {0} / {1} + + + {0} | {1} | {2} 毫秒 + + + 你:{0} + + + OpenClaw:{0} + + + (空) + 语音转文字 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 29fadcc44..5e3a02349 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -2589,6 +2589,105 @@ 設定語音轉文字和語音互動設定。所有語音處理均在本機本地執行。 + + 助理 + + + 永遠聆聽的助理服務和最新對話輪次。 + + + 重新整理助理狀態 + + + 啟動 + + + 停止 + + + 正在檢查助理狀態... + + + 最近輪次 + + + 正在啟動助理... + + + 正在啟動永遠聆聽模式,並使用本機助理路由和輪次儲存。 + + + 助理未啟動 + + + 正在停止助理... + + + 助理未停止 + + + 助理橋接器無法使用 + + + 未載入助理輪次。 + + + 未知 + + + 助理正在聆聽 (PID {0}) + + + 助理已停止 + + + 助理 {0} + + + 轉錄器:{0} + + + 麥克風:{0} + + + 喇叭:{0} + + + 雲端路由:{0} + + + 語音輸出:{0} + + + 開啟 + + + 關閉 + + + 上次橋接器更新:{0} + + + 尚無對話輪次。 + + + 助理 + + + {0} / {1} + + + {0} | {1} | {2} 毫秒 + + + 你:{0} + + + OpenClaw:{0} + + + (空) + 語音轉文字 diff --git a/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs new file mode 100644 index 000000000..7291729f4 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs @@ -0,0 +1,294 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; +using System.Diagnostics; + +namespace OpenClaw.Tray.Tests; + +public sealed class AssistantBridgeServiceTests +{ + [Fact] + public void ParseStatus_ReadsListenServiceAndRecentTurns() + { + const string json = """ + { + "generated_at": "2026-06-21T19:40:30+00:00", + "user_id": "owner", + "assistant": { + "listen_service": { + "allow_cloud": true, + "configured": true, + "input_mode": "always-listening", + "log_file": "C:\\Users\\RipauvGohil\\AppData\\Local\\OpenClaw\\runtime\\assistant-listen-owner.log", + "pid": 20656, + "speak_aloud": true, + "status": "running", + "transcriber": "local-whisper" + }, + "recent_turns": [ + { + "created_at": "2026-06-21T19:26:59+00:00", + "source": "local-whisper", + "input_text": "OpenClaw, say only live voice smoke ok.", + "display_response_text": "live voice smoke ok", + "provider": "local-openai-compatible", + "model_profile": "local-private", + "stage": "answered", + "total_ms": 23502 + } + ] + }, + "voice": { + "preferred_input_device": "Microphone (Yeti Nano)", + "preferred_output_device": "LG TV SSCR2 (NVIDIA High Definition Audio)" + } + } + """; + + var snapshot = AssistantBridgeService.ParseStatus(json); + + Assert.True(snapshot.IsAvailable); + Assert.Equal("owner", snapshot.UserId); + Assert.Equal("2026-06-21T19:40:30+00:00", snapshot.GeneratedAt); + Assert.Equal("Microphone (Yeti Nano)", snapshot.PreferredInputDevice); + Assert.Equal("LG TV SSCR2 (NVIDIA High Definition Audio)", snapshot.PreferredOutputDevice); + Assert.True(snapshot.ListenService.IsRunning); + Assert.True(snapshot.ListenService.AllowCloud); + Assert.True(snapshot.ListenService.SpeakAloud); + Assert.Equal(20656, snapshot.ListenService.Pid); + Assert.Equal("local-whisper", snapshot.ListenService.Transcriber); + Assert.Single(snapshot.RecentTurns); + Assert.Equal("OpenClaw, say only live voice smoke ok.", snapshot.RecentTurns[0].InputText); + Assert.Equal("live voice smoke ok", snapshot.RecentTurns[0].ResponseText); + Assert.Equal(23502, snapshot.RecentTurns[0].TotalMs); + } + + [Fact] + public void ResolveOpenClawCli_PrefersBackendRootEnvironmentOverride() + { + var oldValue = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT"); + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Projects", + "OpenClaw.Tray.Tests", + Guid.NewGuid().ToString("N")); + try + { + var scripts = Path.Combine(root, ".venv", "Scripts"); + Directory.CreateDirectory(scripts); + var exe = Path.Combine(scripts, "openclaw.exe"); + File.WriteAllText(exe, ""); + + Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", root); + + var launcher = AssistantBridgeService.ResolveOpenClawCli(); + + Assert.NotNull(launcher); + Assert.Equal(exe, launcher!.ExecutablePath); + Assert.Equal(root, launcher.WorkingDirectory); + Assert.Empty(launcher.PrefixArgs); + } + finally + { + Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", oldValue); + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + [Fact] + public void ResolveOpenClawCli_IgnoresBackendRootOutsideTrustedParents() + { + var oldValue = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT"); + var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + try + { + var scripts = Path.Combine(root, ".venv", "Scripts"); + Directory.CreateDirectory(scripts); + var exe = Path.Combine(scripts, "openclaw.exe"); + File.WriteAllText(exe, ""); + + Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", root); + + var launcher = AssistantBridgeService.ResolveOpenClawCli(); + + Assert.True( + launcher == null || !string.Equals(launcher.ExecutablePath, exe, StringComparison.OrdinalIgnoreCase), + "OPENCLAW_BACKEND_ROOT should not execute arbitrary binaries outside trusted checkout parents."); + } + finally + { + Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", oldValue); + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + [Fact] + public void TryNormalizeBackendRoot_RejectsRelativePaths() + { + Assert.False(AssistantBridgeService.TryNormalizeBackendRoot("OpenClaw", out _)); + } + + [Fact] + public void TryNormalizeBackendRoot_AcceptsTrustedCheckoutParents() + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Projects", + "OpenClaw"); + + Assert.True(AssistantBridgeService.TryNormalizeBackendRoot(root, out var normalizedRoot)); + Assert.Equal(Path.GetFullPath(root), normalizedRoot); + } + + [Fact] + public void BuildBackendNotFoundMessage_ListsSearchedLocations() + { + var message = AssistantBridgeService.BuildBackendNotFoundMessage(); + + Assert.Contains("Searched:", message); + Assert.Contains(@"D:\Projects\OpenClaw", message); + Assert.Contains("OPENCLAW_BACKEND_ROOT", message); + } + + [Fact] + public async Task StartListenServiceAsync_DefaultsToLocalAssistantRouting() + { + var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + var argsPath = Path.Combine(root, "args.txt"); + Directory.CreateDirectory(root); + try + { + var scriptPath = Path.Combine(root, "capture-args.ps1"); + File.WriteAllText( + scriptPath, + $"Set-Content -LiteralPath {PsQuote(argsPath)} -Value ($args -join [Environment]::NewLine)"); + var launcher = PowerShellScriptLauncher(scriptPath, root); + var service = new AssistantBridgeService( + NullLogger.Instance, + "owner", + () => launcher); + + var result = await service.StartListenServiceAsync(); + + Assert.True(result.Success, result.ErrorMessage); + var args = File.ReadAllLines(argsPath); + Assert.Contains("assistant", args); + Assert.Contains("listen-service", args); + Assert.Contains("start", args); + Assert.Contains("--store-turn", args); + Assert.Contains("--json", args); + Assert.DoesNotContain("--allow-cloud", args); + } + finally + { + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + [Fact] + public async Task StartListenServiceAsync_KillsTimedOutBackendCommand() + { + var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); + var pidPath = Path.Combine(root, "pid.txt"); + Directory.CreateDirectory(root); + int? pid = null; + try + { + var scriptPath = Path.Combine(root, "sleep.ps1"); + File.WriteAllText( + scriptPath, + $"Set-Content -LiteralPath {PsQuote(pidPath)} -Value $PID; Start-Sleep -Seconds 30"); + var launcher = PowerShellScriptLauncher(scriptPath, root); + var service = new AssistantBridgeService( + NullLogger.Instance, + "owner", + () => launcher, + TimeSpan.FromSeconds(2)); + + var result = await service.StartListenServiceAsync(); + + Assert.False(result.Success); + Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + pid = await ReadPidAsync(pidPath); + Assert.True( + await WaitForProcessExitAsync(pid.Value), + $"Expected timed-out backend process {pid.Value} to be killed."); + } + finally + { + if (pid is int runningPid) + TryKillProcess(runningPid); + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + private static OpenClawCliLauncher PowerShellScriptLauncher(string scriptPath, string workingDirectory) + { + var exe = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + if (!File.Exists(exe)) + exe = "powershell.exe"; + + return new OpenClawCliLauncher( + exe, + workingDirectory, + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath]); + } + + private static string PsQuote(string value) => "'" + value.Replace("'", "''") + "'"; + + private static async Task ReadPidAsync(string pidPath) + { + for (var i = 0; i < 50; i++) + { + if (File.Exists(pidPath) && + int.TryParse((await File.ReadAllTextAsync(pidPath)).Trim(), out var pid)) + { + return pid; + } + + await Task.Delay(100); + } + + Assert.Fail("Timed-out backend command did not write its process id."); + return 0; + } + + private static async Task WaitForProcessExitAsync(int pid) + { + for (var i = 0; i < 50; i++) + { + try + { + using var process = Process.GetProcessById(pid); + if (process.HasExited) + return true; + } + catch (ArgumentException) + { + return true; + } + + await Task.Delay(100); + } + + return false; + } + + private static void TryKillProcess(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 85bdecb61..ac9645a8c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -42,6 +42,7 @@ +