iOS: fix gateway connect roles; improve discovery/connect UX; stabilize chat sending

This commit is contained in:
David Proctor
2026-02-09 10:16:18 -07:00
parent ffeed212dc
commit f061b65e1f
11 changed files with 453 additions and 87 deletions

View File

@@ -2,8 +2,10 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Foundation
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
private let gateway: GatewayNodeSession
init(gateway: GatewayNodeSession) {
@@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
// Operator clients receive chat events without node-style subscriptions.
// (chat.subscribe is a node event, not an operator RPC method.)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
struct Params: Codable {
var sessionKey: String
var message: String
@@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
return decoded
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
throw error
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {

View File

@@ -56,23 +56,42 @@ final class GatewayConnectionController {
}
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
return "Missing instanceId (node.instanceId). Try restarting the app."
}
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
else { return }
let resolvedHost: String?
let resolvedPort: Int?
if let host = self.resolveGatewayHost(gateway) {
resolvedHost = host
resolvedPort = gateway.gatewayPort ?? 18789
} else if let fallback = await self.resolveHostPortFromBonjourEndpoint(gateway.endpoint) {
resolvedHost = fallback.host
resolvedPort = fallback.port > 0 ? fallback.port : (gateway.gatewayPort ?? 18789)
} else {
return "Discovery found a gateway service, but no connectable host was advertised (missing lanHost/tailnetDns TXT) and the Bonjour service did not resolve to an address. Try Manual connect."
}
guard let host = resolvedHost, let port = resolvedPort else {
return "Failed to resolve a connectable host/port from discovery."
}
let useTLS = tlsParams?.required == true || gateway.tlsEnabled
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: useTLS) else {
return "Failed to build gateway URL (host=\(host) port=\(port) tls=\(useTLS))."
}
GatewaySettingsStore.saveLastGatewayConnection(
host: host,
port: port,
useTLS: tlsParams?.required == true,
useTLS: useTLS,
stableID: gateway.stableID)
self.didAutoConnect = true
self.startAutoConnect(
@@ -81,6 +100,11 @@ final class GatewayConnectionController {
tls: tlsParams,
token: token,
password: password)
return nil
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
_ = await self.connectWithDiagnostics(gateway)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
@@ -381,6 +405,125 @@ final class GatewayConnectionController {
return nil
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
return (host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
return nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0
) async -> (host: String, port: Int)? {
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
return String(cString: buffer)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()

View File

@@ -75,7 +75,16 @@ final class GatewayDiscoveryModel {
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
// Some iOS versions return TXT records via metadata, not endpoint.txtRecord.
var txt = result.endpoint.txtRecord?.dictionary ?? [:]
if txt.isEmpty {
switch result.metadata {
case let .bonjour(meta):
txt = meta.dictionary
default:
break
}
}
let advertisedName = txt["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)

View File

@@ -0,0 +1,113 @@
import SwiftUI
struct GatewayQuickSetupSheet: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.dismiss) private var dismiss
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
.font(.headline)
Text(verbatim: candidate.debugID)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.connecting)
if let connectError {
Text(connectError)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Button {
self.dismiss()
} label: {
Text("Not now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.connecting)
Toggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.navigationTitle("Quick Setup")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.quickSetupDismissed = true
self.dismiss()
} label: {
Text("Close")
}
}
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
// Prefer whatever discovery says is first; the list is already name-sorted.
self.gatewayController.gateways.first
}
}

View File

@@ -53,6 +53,8 @@ final class NodeAppModel {
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Foundation
@main
struct OpenClawApp: App {
@@ -7,6 +8,7 @@ struct OpenClawApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
@@ -29,3 +31,18 @@ struct OpenClawApp: App {
}
}
}
extension OpenClawApp {
private static func installUncaughtExceptionLogger() {
NSLog("OpenClaw: installing uncaught exception handler")
NSSetUncaughtExceptionHandler { exception in
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
// produce a normal Swift error backtrace.
let reason = exception.reason ?? "(no reason)"
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
for line in exception.callStackSymbols {
NSLog(" %@", line)
}
}
}
}

View File

@@ -4,6 +4,7 @@ import UIKit
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@@ -14,6 +15,7 @@ struct RootCanvas: View {
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@@ -22,11 +24,13 @@ struct RootCanvas: View {
private enum PresentedSheet: Identifiable {
case settings
case chat
case quickSetup
var id: Int {
switch self {
case .settings: 0
case .chat: 1
case .quickSetup: 2
}
}
}
@@ -62,12 +66,16 @@ struct RootCanvas: View {
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
case .quickSetup:
GatewayQuickSetupSheet()
}
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
@@ -154,6 +162,14 @@ struct RootCanvas: View {
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
private func maybeShowQuickSetup() {
guard !self.quickSetupDismissed else { return }
guard self.presentedSheet == nil else { return }
guard self.appModel.gatewayServerName == nil else { return }
guard !self.gatewayController.gateways.isEmpty else { return }
self.presentedSheet = .quickSetup
}
}
private struct CanvasContent: View {

View File

@@ -103,7 +103,6 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
DisclosureGroup("Advanced") {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
@@ -148,67 +147,69 @@ struct SettingsTab: View {
self.gatewayList(showing: .all)
}
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
SecureField("Gateway Password", text: self.$gatewayPassword)
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
} label: {
@@ -418,10 +419,11 @@ struct SettingsTab: View {
ForEach(rows) { gateway in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(gateway.name)
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
Text(verbatim: gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
Text(line)
Text(verbatim: line)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -507,7 +509,10 @@ struct SettingsTab: View {
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway)
let err = await self.gatewayController.connectWithDiagnostics(gateway)
if let err {
self.connectStatus.text = err
}
}
private func connectLastKnown() async {

View File

@@ -814,23 +814,14 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
// Operator clients receive chat events without node-style subscriptions.
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
}
}
private func buildPrompt(transcript: String) -> String {

View File

@@ -184,7 +184,7 @@ public final class OpenClawChatViewModel {
let decoded = raw.compactMap { item in
(try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self))
}
return Self.dedupeMessages(decoded)
return Self.filterHeartbeatNoise(Self.dedupeMessages(decoded))
}
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
@@ -205,6 +205,56 @@ public final class OpenClawChatViewModel {
return result
}
private static func filterHeartbeatNoise(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
messages.filter { !Self.isHeartbeatNoiseMessage($0) }
}
private static func isHeartbeatNoiseMessage(_ message: OpenClawChatMessage) -> Bool {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return false }
if role == "assistant", Self.isHeartbeatAckText(text) {
return true
}
if role == "user", Self.isHeartbeatPollText(text) {
return true
}
// Some models occasionally echo the heartbeat prompt text as an assistant reply.
if role == "assistant", Self.isHeartbeatPollText(text) {
return true
}
return false
}
private static func isHeartbeatPollText(_ text: String) -> Bool {
let lower = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
// Match the default heartbeat prompt without requiring the entire multi-sentence string.
return lower.hasPrefix("read heartbeat.md if it exists")
}
private static func isHeartbeatAckText(_ text: String) -> Bool {
// Heartbeat acks are intended to be internal. Treat common markup wrappers as equivalent.
var t = text.trimmingCharacters(in: .whitespacesAndNewlines)
if t.isEmpty { return false }
// Strip a few common wrappers (markdown/HTML) so **HEARTBEAT_OK** or <b>HEARTBEAT_OK</b> still matches.
let wrappers = ["**", "__", "`", "<b>", "</b>", "<strong>", "</strong>"]
for w in wrappers {
t = t.replacingOccurrences(of: w, with: "")
}
t = t.trimmingCharacters(in: .whitespacesAndNewlines)
// Allow a tiny amount of padding (some channels append a marker/emoji).
if t == "HEARTBEAT_OK" { return true }
if t.hasPrefix("HEARTBEAT_OK") {
let rest = t.dropFirst("HEARTBEAT_OK".count)
return rest.trimmingCharacters(in: .whitespacesAndNewlines).count <= 10
}
return false
}
private static func dedupeKey(for message: OpenClawChatMessage) -> String? {
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap(\.text).joined(separator: "\n")
@@ -214,14 +264,22 @@ public final class OpenClawChatViewModel {
}
private func performSend() async {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
if self.isSending {
chatUILogger.info("performSend ignored: already sending")
return
}
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty && self.attachments.isEmpty {
chatUILogger.info("performSend ignored: empty input and no attachments")
return
}
// Health checks are best-effort. If they fail (or the gateway doesn't implement them),
// we still attempt to send and let the RPC result determine success.
if !self.healthOK {
self.errorText = "Gateway health unknown; attempting send anyway"
}
chatUILogger.info("performSend sending len=\(trimmed.count, privacy: .public) attachments=\(self.attachments.count, privacy: .public) sessionKey=\(self.sessionKey, privacy: .public)")
self.isSending = true
self.errorText = nil

View File

@@ -399,8 +399,12 @@ public actor GatewayChannelActor {
role: String
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
let code = res.error?["code"]?.value as? String
let msg = res.error?["message"]?.value as? String
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
acc[pair.key] = AnyCodable(pair.value.value)
}
throw GatewayResponseError(method: "connect", code: code, message: msg, details: details)
}
guard let payload = res.payload else {
throw NSError(