diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index d8af7a0bd..08ea6a7c1 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -35,6 +35,7 @@ public interface IOperatorGatewayClient event EventHandler? AgentsListUpdated; event EventHandler? AgentFilesListUpdated; event EventHandler? AgentFileContentUpdated; + event EventHandler? ChatEventReceived; // ─── Query ─── string? OperatorDeviceId { get; } @@ -53,6 +54,7 @@ public interface IOperatorGatewayClient // ─── Request Methods ─── Task SendChatMessageAsync(string message, string? sessionKey = null); + Task SendChatMessageForRunAsync(string message, string? sessionKey = null); Task CheckHealthAsync(); Task RequestSessionsAsync(string? agentId = null); Task RequestUsageAsync(); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 0c39e0800..d38d0fb08 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1660,6 +1660,13 @@ public string DataJson } } +public sealed class ChatSendResult +{ + public string? RunId { get; init; } + public string? SessionKey { get; init; } + public bool Cached { get; init; } +} + // ── Node/Device Pairing ── public class PairingRequest diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 324a51a14..5504f2031 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -38,7 +38,7 @@ public class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatewayClient private GatewayUsageStatusInfo? _usageStatus; private GatewayCostUsageInfo? _usageCost; private readonly Dictionary _pendingRequestMethods = new(); - private readonly Dictionary> _pendingChatSendRequests = new(); + private readonly Dictionary> _pendingChatSendRequests = new(); private readonly object _pendingRequestLock = new(); private readonly object _pendingChatSendLock = new(); private readonly object _sessionsLock = new(); @@ -175,6 +175,7 @@ protected override void OnDisposing() public event EventHandler? AgentsListUpdated; public event EventHandler? AgentFilesListUpdated; public event EventHandler? AgentFileContentUpdated; + public event EventHandler? ChatEventReceived; /// Raised when a device token is received from the gateway during hello-ok handshake. public event EventHandler? DeviceTokenReceived; @@ -251,6 +252,11 @@ public async Task CheckHealthAsync() } public async Task SendChatMessageAsync(string message, string? sessionKey = null) + { + _ = await SendChatMessageForRunAsync(message, sessionKey).ConfigureAwait(false); + } + + public async Task SendChatMessageForRunAsync(string message, string? sessionKey = null) { if (!IsConnected) throw new InvalidOperationException("Gateway connection is not open"); @@ -262,7 +268,7 @@ public async Task SendChatMessageAsync(string message, string? sessionKey = null : sessionKey.Trim(); var requestId = Guid.NewGuid().ToString(); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); TrackPendingChatSend(requestId, completion); var req = new @@ -287,8 +293,9 @@ public async Task SendChatMessageAsync(string message, string? sessionKey = null throw new TimeoutException("Timed out waiting for chat.send response from gateway"); } - await completion.Task; + var result = await completion.Task.ConfigureAwait(false); _logger.Info($"Sent chat message ({message.Length} chars)"); + return result; } /// @@ -886,7 +893,7 @@ private void ClearPendingRequests() _pendingWizardResponses.Clear(); } - private void TrackPendingChatSend(string requestId, TaskCompletionSource completion) + private void TrackPendingChatSend(string requestId, TaskCompletionSource completion) { lock (_pendingChatSendLock) { @@ -902,7 +909,7 @@ private void RemovePendingChatSend(string requestId) } } - private TaskCompletionSource? TakePendingChatSend(string? requestId) + private TaskCompletionSource? TakePendingChatSend(string? requestId) { if (string.IsNullOrWhiteSpace(requestId)) { @@ -975,7 +982,7 @@ private void HandleResponse(JsonElement root) return; } - pendingChatSend.TrySetResult(true); + pendingChatSend.TrySetResult(ParseChatSendResult(root)); return; } @@ -1205,6 +1212,36 @@ private bool HandleKnownResponse(string method, JsonElement payload) } } + private static ChatSendResult ParseChatSendResult(JsonElement root) + { + string? runId = null; + string? sessionKey = null; + var cached = false; + + if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) + { + if (payload.TryGetProperty("runId", out var runIdProp)) + runId = runIdProp.GetString(); + if (payload.TryGetProperty("sessionKey", out var sessionKeyProp)) + sessionKey = sessionKeyProp.GetString(); + } + + if (root.TryGetProperty("meta", out var meta) && + meta.ValueKind == JsonValueKind.Object && + meta.TryGetProperty("cached", out var cachedProp) && + cachedProp.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + cached = cachedProp.GetBoolean(); + } + + return new ChatSendResult + { + RunId = runId, + SessionKey = sessionKey, + Cached = cached + }; + } + private void HandleRequestError(string? method, JsonElement root) { var message = TryGetErrorMessage(root) ?? "request failed"; @@ -1979,6 +2016,7 @@ private void HandleChatEvent(JsonElement root) _logger.Debug($"Chat event received: {rawText[..Math.Min(200, rawText.Length)]}"); if (!root.TryGetProperty("payload", out var payload)) return; + EmitRawChatEvent(payload); // Try new format: payload.message.role + payload.message.content[].text if (payload.TryGetProperty("message", out var message)) @@ -2021,6 +2059,38 @@ private void HandleChatEvent(JsonElement root) } } + private void EmitRawChatEvent(JsonElement payload) + { + try + { + var stream = "chat"; + if (payload.TryGetProperty("message", out var message) && + message.TryGetProperty("role", out var roleProp)) + { + stream = roleProp.GetString() ?? stream; + } + else if (payload.TryGetProperty("role", out var legacyRoleProp)) + { + stream = legacyRoleProp.GetString() ?? stream; + } + + var evt = new AgentEventInfo + { + RunId = payload.TryGetProperty("runId", out var rid) ? rid.GetString() ?? "" : "", + Seq = payload.TryGetProperty("seq", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number ? seqProp.GetInt32() : 0, + Stream = stream, + Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Data = payload.Clone(), + SessionKey = payload.TryGetProperty("sessionKey", out var sk) ? sk.GetString() : null + }; + ChatEventReceived?.Invoke(this, evt); + } + catch (Exception ex) + { + _logger.Warn($"Failed to emit chat event: {ex.Message}"); + } + } + private void EmitChatNotification(string text) { var displayText = text.Length > 200 ? text[..200] + "…" : text; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index dae0e2dc6..93dc96324 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -482,7 +482,8 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) nodeConnector: nodeConnector, isNodeEnabled: ShouldInitializeNodeService, diagnostics: diagnostics, - tunnelManager: tunnelManager); + tunnelManager: tunnelManager, + shouldStartNodeConnection: ShouldInitializeNodeService); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; @@ -2189,6 +2190,27 @@ private bool ShouldInitializeNodeService() return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true; } + private bool ShouldInitializeNodeService(GatewayRecord activeGateway, string managerIdentityPath) + { + if (!ShouldInitializeNodeService()) return false; + + if (LocalNodeServiceOwnsIdentityFor(activeGateway)) + { + Logger.Info("[ConnMgr] Suppressing manager-owned NodeConnector because local NodeService owns the active local gateway identity"); + return false; + } + + return true; + } + + private bool LocalNodeServiceOwnsIdentityFor(GatewayRecord activeGateway) + { + if (!activeGateway.IsLocal || _settings == null) return false; + if (!StartupSetupState.HasStoredNodeDeviceToken(IdentityDataPath)) return false; + + return EnsureNodeServiceForLocalGatewaySetup(_settings) != null; + } + private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { Logger.Info($"Node status: {status}"); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 8e45e583b..516cbea43 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -446,7 +446,6 @@ private async Task InitializeChatWebViewAsync() })(); "); - _ = SendBootstrapMessageAsync(); } }); }; @@ -517,25 +516,6 @@ private void ShowChatError(string message) } } - private bool _bootstrapSent; - - /// - /// Auto-sends the bootstrap kickoff message after the web chat loads. - /// Delegates to so the same gated - /// kickoff fires from both the (legacy) onboarding chat overlay and from - /// post-wizard HubWindow chat navigation — guarded by - /// . - /// - private async Task SendBootstrapMessageAsync() - { - if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return; - _bootstrapSent = true; - - await BootstrapMessageInjector.InjectAsync( - script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(), - _settings); - } - /// /// Captures the current window content to a PNG file. /// Called automatically on page navigation when OPENCLAW_VISUAL_TEST=1. diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml index c95e88bfa..7f4400374 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml @@ -56,7 +56,23 @@ + HorizontalAlignment="Center" VerticalAlignment="Center"/> + + + + + +