import AppKit import Foundation import OpenClawChatUI import OpenClawKit import OpenClawProtocol import OSLog import QuartzCore import SwiftUI private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI") private let webChatThinkingLevelDefaultsKey = "openclaw.webchat.thinkingLevel" private let webChatVerboseLevelDefaultsKey = "openclaw.webchat.verboseLevel" private enum WebChatSwiftUILayout { static let windowSize = NSSize(width: 960, height: 700) static let panelSize = NSSize(width: 480, height: 640) static let windowMinSize = NSSize(width: 640, height: 420) static let windowFrameAutosaveName = "OpenClawChatWindow" static let anchorPadding: CGFloat = 8 } enum WebChatTracePreferences { static func displayOptions(defaults: UserDefaults = .standard) -> OpenClawChatDisplayOptions { if let legacyValue = defaults.object( forKey: OpenClawChatWindowShell.assistantTraceDefaultsKey) as? Bool { for key in [ OpenClawChatWindowShell.assistantReasoningDefaultsKey, OpenClawChatWindowShell.assistantToolActivityDefaultsKey, ] where defaults.object(forKey: key) == nil { defaults.set(legacyValue, forKey: key) } } var options: OpenClawChatDisplayOptions = [] if defaults.object(forKey: OpenClawChatWindowShell.assistantReasoningDefaultsKey) as? Bool ?? true { options.insert(.reasoning) } if defaults.object(forKey: OpenClawChatWindowShell.assistantToolActivityDefaultsKey) as? Bool ?? true { options.insert(.toolActivity) } return options } } /// SwiftUI's native toolbar bridge may restore visible title chrome while it /// installs toolbar items. Keep the full-window chat's titlebar merged. private final class WebChatWindow: NSWindow { var pinnedTitle: String? override var title: String { didSet { // SwiftUI toolbar bridging may replace the operator-facing Gateway // name with a session key. Keep Mission Control/window lists useful. if let pinnedTitle, title != pinnedTitle { self.title = pinnedTitle } } } override var titleVisibility: NSWindow.TitleVisibility { didSet { if self.titleVisibility != .hidden { self.titleVisibility = .hidden } } } } struct MacGatewayChatTransport: OpenClawChatTransport { /// Shared across transport value copies so the live view model and its /// snapshot observer cannot diverge on the owner of the bare global alias. private final class RoutingIdentity: @unchecked Sendable { private let lock = NSLock() private var defaultGlobalAgentID: String? init(defaultGlobalAgentID: String?) { self.defaultGlobalAgentID = Self.normalized(defaultGlobalAgentID) } func update(defaultGlobalAgentID: String?) { self.lock.withLock { self.defaultGlobalAgentID = Self.normalized(defaultGlobalAgentID) } } func currentAgentID() -> String? { self.lock.withLock { self.defaultGlobalAgentID } } private static func normalized(_ agentID: String?) -> String? { let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized?.isEmpty == false ? normalized : nil } } typealias SessionTarget = OpenClawChatSessionTarget let connection: GatewayConnection let outboxGatewayID: String? private let routingIdentity: RoutingIdentity init( connection: GatewayConnection = .shared, outboxGatewayID: String? = nil, defaultGlobalAgentID: String? = nil) { self.connection = connection self.outboxGatewayID = outboxGatewayID self.routingIdentity = RoutingIdentity(defaultGlobalAgentID: defaultGlobalAgentID) } func updateDefaultGlobalAgentID(_ agentID: String?) { self.routingIdentity.update(defaultGlobalAgentID: agentID) } func currentOutboxGatewayMatchesConnection() async -> Bool { guard self.connection === GatewayConnection.shared, let outboxGatewayID else { return true } let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() } return currentGatewayID == outboxGatewayID } func requireCurrentOutboxGateway() async throws { guard await self.currentOutboxGatewayMatchesConnection() else { throw OpenClawChatTransportSendError.notDispatched } } func sessionTarget(for sessionKey: String, overrideAgentID: String? = nil) -> SessionTarget { OpenClawChatSessionTarget.resolve( sessionKey, selectedAgentID: self.routingIdentity.currentAgentID(), overrideAgentID: overrideAgentID, policy: .preserveBareKeys) } var outboxRequiresSessionRoutingContract: Bool { true } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { let target = self.sessionTarget(for: sessionKey) return try await self.connection.chatHistory( sessionKey: target.sessionKey, agentID: target.agentID) } func requestFullMessage(sessionKey: String, messageID: String) async throws -> OpenClawChatMessage? { let target = self.sessionTarget(for: sessionKey) let request = try Self.fullMessageRequest( sessionKey: target.sessionKey, agentID: target.agentID, messageID: messageID) let data = try await connection.request(request) let result = try JSONDecoder().decode(ChatMessageGetResult.self, from: data) guard result.ok, let encodedMessage = result.message else { return nil } return try JSONDecoder().decode( OpenClawChatMessage.self, from: JSONEncoder().encode(encodedMessage)) } static func fullMessageRequest( sessionKey: String, agentID: String?, messageID: String) throws -> OpenClawChatGatewayRequest { let params = ChatMessageGetParams( sessionkey: sessionKey, agentid: agentID, messageid: messageID, maxchars: 500_000) let encoded = try JSONEncoder().encode(params) return try OpenClawChatGatewayRequest( method: "chat.message.get", params: JSONDecoder().decode([String: AnyCodable].self, from: encoded), timeoutMs: 15000) } func resolveInlineWidgetResource( path: String, replacing failedResource: OpenClawChatWidgetResource?) async -> OpenClawChatWidgetResource? { await OpenClawChatWidgetURLResolver.resolveResource( target: path, replacing: failedResource, currentSurfaceRoutes: { let node = await MacNodeModeCoordinator.shared.currentCanvasPluginSurfaceRoute() let operatorSurface = await self.connection.canvasPluginSurfaceRoute() return (node: node, operatorSurface: operatorSurface) }, // Prefer the local node route; operator rotation keeps chat usable // while macOS node mode is disabled or reconnecting. refreshNodeSurfaceRoute: { observed in await MacNodeModeCoordinator.shared.refreshCanvasPluginSurfaceRoute(replacing: observed?.url) }, refreshOperatorSurfaceRoute: { observed in await self.connection.refreshCanvasPluginSurfaceRoute(replacing: observed?.url) }) } func resolveInlineWidgetURL(path: String, replacing failedURL: URL?) async -> URL? { await self.resolveInlineWidgetResource( path: path, replacing: failedURL.map { OpenClawChatWidgetResource(url: $0) })?.url } func listModels() async throws -> [OpenClawChatModelChoice] { do { let data = try await connection.request(OpenClawChatGatewayRequests.modelsList()) return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(data) } catch { webChatSwiftLogger.warning( "models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)") return [] } } func acquireSwarmRouteLease() async -> OpenClawChatSwarmRouteLease? { guard let lease = await self.connection.captureServerLease() else { return nil } let transport = self return OpenClawChatSwarmRouteLease( isEnabled: { sessionKey in try await transport.isSwarmEnabled(sessionKey: sessionKey, serverLease: lease) }, listChildSessions: { parentKey in try await transport.listChildSessions(parentKey: parentKey, serverLease: lease) }) } func isSwarmEnabled(sessionKey: String) async throws -> Bool { try await self.isSwarmEnabled(sessionKey: sessionKey, serverLease: nil) } private func isSwarmEnabled( sessionKey: String, serverLease: GatewayConnection.ServerLease?) async throws -> Bool { let request = OpenClawChatGatewayRequests.chatMetadata( sessionKey: sessionKey, fallbackAgentID: self.routingIdentity.currentAgentID()) let data: Data = if let serverLease { try await self.connection.request( method: request.method, params: request.params, timeoutMs: request.timeoutMs, ifCurrentServerLease: serverLease) } else { try await self.connection.request(request) } return try JSONDecoder().decode(OpenClawChatMetadataCapabilities.self, from: data).swarmEnabled } func abortRun(sessionKey: String, runId: String) async throws { let target = self.sessionTarget(for: sessionKey) let request = OpenClawChatGatewayRequests.abortRun( sessionKey: target.sessionKey, agentID: target.agentID, runID: runId) _ = try await self.connection.request(request) } func listSessions( limit: Int?, search: String?, archived: Bool) async throws -> OpenClawChatSessionsListResponse { let request = OpenClawChatGatewayRequests.sessionsList( limit: limit, search: search, archived: archived) let data = try await connection.request(request) let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) let mainSessionKey = await connection.cachedMainSessionKey() let defaults = decoded.defaults.map { OpenClawChatSessionsDefaults( modelProvider: $0.modelProvider, model: $0.model, contextTokens: $0.contextTokens, thinkingLevels: $0.thinkingLevels, thinkingOptions: $0.thinkingOptions, thinkingDefault: $0.thinkingDefault, mainSessionKey: mainSessionKey) } ?? OpenClawChatSessionsDefaults( model: nil, contextTokens: nil, mainSessionKey: mainSessionKey) return OpenClawChatSessionsListResponse( ts: decoded.ts, path: decoded.path, count: decoded.count, totalCount: decoded.totalCount, offset: decoded.offset, nextOffset: decoded.nextOffset, hasMore: decoded.hasMore, defaults: defaults, sessions: decoded.sessions) } func listChildSessions(parentKey: String) async throws -> [OpenClawChatSessionEntry] { try await self.listChildSessions(parentKey: parentKey, serverLease: nil) } private func listChildSessions( parentKey: String, serverLease: GatewayConnection.ServerLease?) async throws -> [OpenClawChatSessionEntry] { try await OpenClawChatChildSessionPager.collect { offset in let request = OpenClawChatGatewayRequests.sessionsList( limit: 10000, search: nil, archived: false, includeGlobal: false, spawnedBy: parentKey, offset: offset, configuredAgentsOnly: true) let data: Data = if let serverLease { try await self.connection.request( method: request.method, params: request.params, timeoutMs: request.timeoutMs, ifCurrentServerLease: serverLease) } else { try await self.connection.request(request) } return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) } } func listAgents() async throws -> OpenClawChatAgentsListResponse? { let data = try await connection.request(OpenClawChatGatewayRequests.agentsList()) let result = try JSONDecoder().decode(AgentsListResult.self, from: data) return OpenClawChatAgentsListResponse( defaultId: result.defaultid, agents: result.agents.filter(\.isSelectableAgent).map { OpenClawChatAgentChoice( id: $0.id, name: $0.name, workspaceGit: $0.workspacegit) }) } func listSessionGroups() async throws -> OpenClawChatSessionGroupsResponse? { let data = try await connection.request(OpenClawChatGatewayRequests.sessionGroupsList()) return try JSONDecoder().decode(OpenClawChatSessionGroupsResponse.self, from: data) } func putSessionGroups(names: [String]) async throws -> OpenClawChatSessionGroupsMutationResponse { let request = OpenClawChatGatewayRequests.sessionGroupsPut(names: names) let data = try await connection.request(request) return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data) } func renameSessionGroup( name: String, to: String) async throws -> OpenClawChatSessionGroupsMutationResponse { let request = OpenClawChatGatewayRequests.sessionGroupsRename(name: name, to: to) let data = try await connection.request(request) return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data) } func deleteSessionGroup(name: String) async throws -> OpenClawChatSessionGroupsMutationResponse { let request = OpenClawChatGatewayRequests.sessionGroupsDelete(name: name) let data = try await connection.request(request) return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data) } func setSessionModel(sessionKey: String, model: String?) async throws { let target = self.sessionTarget(for: sessionKey) _ = try await self.patchSessionModel( sessionKey: target.sessionKey, agentID: target.agentID, model: model) } func patchSessionModel( sessionKey: String, agentID: String?, model: String?) async throws -> OpenClawChatModelPatchResult? { try await self.patchSessionSettings( sessionKey: sessionKey, agentID: agentID, patch: OpenClawChatSessionSettingsPatch(model: .some(model))) } func patchSessionSettings( sessionKey: String, agentID: String?, patch: OpenClawChatSessionSettingsPatch) async throws -> OpenClawChatModelPatchResult? { try await self.patchSessionSettings( sessionKey: sessionKey, agentID: agentID, patch: patch, serverLease: nil) } private func patchSessionSettings( sessionKey: String, agentID: String?, patch: OpenClawChatSessionSettingsPatch, serverLease: GatewayConnection.ServerLease?) async throws -> OpenClawChatModelPatchResult? { let target = OpenClawChatSessionTarget.resolve( sessionKey, selectedAgentID: self.routingIdentity.currentAgentID(), overrideAgentID: agentID, policy: .preserveBareKeys) let request = Self.sessionSettingsRequest( sessionKey: target.sessionKey, agentID: target.agentID, patch: patch) let data: Data = if let serverLease { try await self.connection.request( method: request.method, params: request.params, timeoutMs: request.timeoutMs, ifCurrentServerLease: serverLease) } else { try await self.connection.request(request) } return try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data) } static func sessionSettingsRequest( sessionKey: String, agentID: String?, patch: OpenClawChatSessionSettingsPatch) -> OpenClawChatGatewayRequest { OpenClawChatGatewayRequests.patchSessionSettings( sessionKey: sessionKey, agentID: agentID, model: patch.model, thinkingLevel: patch.thinkingLevel, fastMode: patch.fastMode, verboseLevel: patch.verboseLevel) } func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? { guard await self.currentOutboxGatewayMatchesConnection() else { return nil } guard let serverLease = await connection.captureServerLease() else { return nil } let transport = self return OpenClawChatSessionSettingsRouteLease { sessionKey, agentID, patch in try await transport.requireCurrentOutboxGateway() return try await transport.patchSessionSettings( sessionKey: sessionKey, agentID: agentID, patch: patch, serverLease: serverLease) } } func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws { let target = self.sessionTarget(for: sessionKey) _ = try await self.patchSessionSettings( sessionKey: target.sessionKey, agentID: target.agentID, patch: OpenClawChatSessionSettingsPatch(thinkingLevel: .some(thinkingLevel))) } func sendMessage( sessionKey: String, message: String, thinking: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { let target = self.sessionTarget(for: sessionKey) return try await self.connection.chatSend( sessionKey: target.sessionKey, agentID: target.agentID, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments) } func sendMessage( sessionKey: String, agentID: String?, expectedSessionRoutingContract: String?, message: String, thinking: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { let target = self.sessionTarget(for: sessionKey) try await self.requireCurrentOutboxGateway() guard let route = await connection.captureRoute(), let supportsRoutingContract = await connection.supportsServerCapability( .chatSendRoutingContract, ifCurrentRoute: route) else { throw OpenClawChatTransportSendError.notDispatched } // Outbox replay is capability-gated in acquireOutboxRouteLease. A // live send keeps its captured route on older gateways and omits the // unsupported atomic routing field. let guardedContract = OpenClawChatSessionRoutingContract.expectedValue( expectedSessionRoutingContract, serverSupportsGuard: supportsRoutingContract) return try await self.connection.chatSend( sessionKey: target.sessionKey, agentID: agentID ?? target.agentID, expectedSessionRoutingContract: guardedContract, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments, ifCurrentRoute: route, distinguishPreDispatchRouteChange: true) } func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { guard self.outboxGatewayID != nil, await self.currentOutboxGatewayMatchesConnection() else { return .unavailable(reason: nil) } guard let route = await connection.captureRoute() else { return .unavailable(reason: nil) } guard let supportsRoutingContract = await connection.supportsServerCapability( .chatSendRoutingContract, ifCurrentRoute: route) else { return .unavailable(reason: nil) } guard supportsRoutingContract else { return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract) } guard let routingIdentity = try? await connection.sessionRoutingIdentity( ifCurrentRoute: route) else { return .unavailable(reason: nil) } let routingContract = routingIdentity.contract return .available(OpenClawChatTransportRouteLease( sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in try await self.requireCurrentOutboxGateway() return try await self.connection.chatSend( sessionKey: sessionKey, agentID: agentID, expectedSessionRoutingContract: routingContract, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments, ifCurrentRoute: route, distinguishPreDispatchRouteChange: true) }, requestTargetedHistory: { sessionKey, agentID in try await self.requireCurrentOutboxGateway() return try await self.connection.chatHistory( sessionKey: sessionKey, agentID: agentID, ifCurrentRoute: route) }, sessionRoutingContract: routingContract)) } func synthesizeSpeech(text: String) async throws -> OpenClawChatSpeechClip { // Capture the lease before validating the pinned gateway: a gateway // switch after validation then fails the request via the lease guard // instead of re-routing the text to the newly selected gateway. guard let serverLease = await connection.captureServerLease() else { throw OpenClawChatTransportSendError.notDispatched } try await self.requireCurrentOutboxGateway() return try await MacChatMessageSpeechClient.synthesize( text: text, serverLease: serverLease, connection: self.connection) } func loadMediaArtifact( sessionKey: String, artifactId: String, kind: OpenClawChatMediaKind, playback: OpenClawChatPlaybackMode?) async throws -> OpenClawChatLoadedMedia? { guard let serverLease = await connection.captureServerLease() else { throw OpenClawChatTransportSendError.notDispatched } let target = self.sessionTarget(for: sessionKey) return try await self.connection.loadMediaArtifact( sessionKey: target.sessionKey, agentID: target.agentID, artifactId: artifactId, kind: kind, playback: playback, ifCurrentServerLease: serverLease) } var supportsSlashCommandCatalog: Bool { true } func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] { let request = OpenClawChatGatewayRequests.commandsList( sessionKey: sessionKey, fallbackAgentID: self.routingIdentity.currentAgentID()) let data = try await connection.request(request) let decoded = try JSONDecoder().decode(CommandsListResult.self, from: data) return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice) } func createSession( key: String, label: String?, parentSessionKey: String?, worktree: Bool?) async throws -> OpenClawChatCreateSessionResponse { try await self.createSession( key: key, label: label, agentID: nil, parentSessionKey: parentSessionKey, worktree: worktree, worktreeBaseRef: nil) } func createSession( key: String, label: String?, agentID explicitAgentID: String?, parentSessionKey: String?, worktree: Bool?, worktreeBaseRef: String?) async throws -> OpenClawChatCreateSessionResponse { let agentID = explicitAgentID ?? OpenClawChatSessionKey.agentID(from: key) ?? parentSessionKey.flatMap { OpenClawChatSessionKey.agentID(from: $0) } ?? self.routingIdentity.currentAgentID() let request = OpenClawChatGatewayRequests.createSession( key: key, agentID: agentID, label: label, parentSessionKey: parentSessionKey, worktree: worktree, worktreeBaseRef: worktreeBaseRef) let data = try await connection.request(request) return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data) } func patchSession( key: String, label: String??, category: String??, pinned: Bool?, archived: Bool?, unread: Bool?) async throws { let target = self.sessionTarget(for: key) let request = OpenClawChatGatewayRequests.patchSession( sessionKey: target.sessionKey, agentID: target.agentID, label: label, category: category, pinned: pinned, archived: archived, unread: unread) _ = try await self.connection.request(request) } func deleteSession(key: String) async throws { let target = self.sessionTarget(for: key) let request = OpenClawChatGatewayRequests.deleteSession( sessionKey: target.sessionKey, agentID: target.agentID) _ = try await self.connection.request(request) } func requestHealth(timeoutMs: Int) async throws -> Bool { try await self.connection.healthOK(timeoutMs: timeoutMs) } func listQuestions() async throws -> [QuestionRecord] { let data = try await connection.request(OpenClawChatGatewayRequests.questionList()) return try JSONDecoder().decode(QuestionListResult.self, from: data).questions } func getQuestion(id: String) async throws -> QuestionRecord { let data = try await connection.request(OpenClawChatGatewayRequests.questionGet(id: id)) return try JSONDecoder().decode(QuestionGetResult.self, from: data).question } func resolveQuestion(id: String, answers: [String: [String]]) async throws { _ = try await self.connection.request( OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers)) } func cancelQuestion(id: String) async throws { _ = try await self.connection.request( OpenClawChatGatewayRequests.cancelQuestion(id: id)) } func waitForRunCompletion( runId rawRunId: String, timeoutMs: Int) async -> OpenClawChatRunObservation { let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines) guard !runId.isEmpty, let route = await connection.captureRoute() else { return .unavailable } do { let request = OpenClawChatGatewayRequests.agentWait(runID: runId, timeoutMs: timeoutMs) let data = try await connection.request( request, ifCurrentRoute: route) return try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(data) } catch { webChatSwiftLogger.warning( "agent.wait failed runId=\(runId, privacy: .public) " + "error=\(error.localizedDescription, privacy: .public)") return .unavailable } } func resetSession(sessionKey: String) async throws { let target = self.sessionTarget(for: sessionKey) let request = OpenClawChatGatewayRequests.resetSession( sessionKey: target.sessionKey, agentID: target.agentID) _ = try await self.connection.request(request) } func compactSession(sessionKey: String) async throws { let target = self.sessionTarget(for: sessionKey) let request = OpenClawChatGatewayRequests.compactSession( sessionKey: target.sessionKey, agentID: target.agentID) let response = try await connection.request(request, retryTransportFailures: false) try OpenClawSessionsCompactResponse.requireSuccess(from: response) } func setActiveSessionKey(_ sessionKey: String) async throws { await MainActor.run { WebChatManager.shared.recordActiveSessionKey(sessionKey) } let target = self.sessionTarget(for: sessionKey) let request = OpenClawChatGatewayRequests.subscribeSessionMessages( sessionKey: target.sessionKey, agentID: target.agentID) _ = try await self.connection.request(request) } func events() -> AsyncStream { AsyncStream { continuation in let task = Task { do { try await self.connection.refresh() } catch { webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") } let stream = await self.connection.subscribe() var hasSeenSnapshot = false for await push in stream { if Task.isCancelled { return } if case .snapshot = push { if hasSeenSnapshot { continuation.yield(.routeChanged) } hasSeenSnapshot = true } if let evt = Self.mapPushToTransportEvent(push) { continuation.yield(evt) } } } continuation.onTermination = { @Sendable _ in task.cancel() } } } static func mapPushToTransportEvent(_ push: GatewayPush) -> OpenClawChatTransportEvent? { switch push { case let .snapshot(hello): let ok = (try? JSONDecoder().decode( OpenClawGatewayHealthOK.self, from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true return .health(ok: ok) case let .event(evt): return OpenClawChatGatewayPayloadCodec.event(from: evt) case .seqGap: return .seqGap } } } // MARK: - Window controller private enum MacChatMessageSpeechError: LocalizedError { case invalidRequest case emptyAudio case unsupportedTransport var errorDescription: String? { switch self { case .invalidRequest: "Failed to encode tts.speak request" case .emptyAudio: "Gateway tts.speak returned empty audio" case .unsupportedTransport: "Gateway TTS is unavailable for this chat transport" } } } private enum MacChatMessageSpeechClient { private static let requestTimeoutMs: Double = 60000 static func synthesize( text: String, serverLease: GatewayConnection.ServerLease, connection: GatewayConnection) async throws -> OpenClawChatSpeechClip { let encoded = try JSONEncoder().encode(TtsSpeakParams(text: text)) guard let params = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { throw MacChatMessageSpeechError.invalidRequest } let responseData = try await connection.request( method: "tts.speak", params: params.mapValues(AnyCodable.init), timeoutMs: self.requestTimeoutMs, ifCurrentServerLease: serverLease) let response = try JSONDecoder().decode(TtsSpeakResult.self, from: responseData) guard let audioData = Data(base64Encoded: response.audiobase64), !audioData.isEmpty else { throw MacChatMessageSpeechError.emptyAudio } return OpenClawChatSpeechClip( data: audioData, outputFormat: response.outputformat, mimeType: response.mimetype, fileExtension: response.fileextension) } } @MainActor private struct MacChatSurface: View { @State private var viewModel: OpenClawChatViewModel @State private var appState = AppStateStore.shared @State private var talkController = TalkModeController.shared @State private var audioInputCatalog = MacChatAudioInputCatalog() @AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey) private var showsReasoning = WebChatTracePreferences.displayOptions().contains(.reasoning) @AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey) private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity) private let isFullWindow: Bool private let userAccent: Color? private let usesPrimaryAppRuntime: Bool private let speech: OpenClawChatSpeechController private let voiceNoteRecorder: OpenClawVoiceNoteRecorder init( viewModel: OpenClawChatViewModel, isFullWindow: Bool, userAccent: Color?, usesPrimaryAppRuntime: Bool, speech: OpenClawChatSpeechController, voiceNoteRecorder: OpenClawVoiceNoteRecorder) { _viewModel = State(initialValue: viewModel) self.isFullWindow = isFullWindow self.userAccent = userAccent self.usesPrimaryAppRuntime = usesPrimaryAppRuntime self.speech = speech self.voiceNoteRecorder = voiceNoteRecorder } var body: some View { Group { if self.isFullWindow { OpenClawChatWindowShell( viewModel: self.viewModel, userAccent: self.userAccent, displayOptions: self.displayOptions, emptyAssistantIntro: Self.emptyAssistantIntro, emptyAssistantPrompts: Self.emptyAssistantPrompts, talkControl: self.talkControl, voiceNoteControl: self.voiceNoteControl, speech: self.speech, mediaPlaybackAllowed: { !AppStateStore.shared.talkEnabled && !self.voiceNoteRecorder.ownsPendingChatAttachment }) } else { OpenClawChatView( viewModel: self.viewModel, showsSessionSwitcher: true, userAccent: self.userAccent, emptyAssistantIntro: Self.emptyAssistantIntro, emptyAssistantPrompts: Self.emptyAssistantPrompts, talkControl: self.talkControl, voiceNoteControl: self.voiceNoteControl, speech: self.speech, mediaPlaybackAllowed: { !AppStateStore.shared.talkEnabled && !self.voiceNoteRecorder.ownsPendingChatAttachment }) } } .onAppear { self.audioInputCatalog.start() } .onDisappear { self.audioInputCatalog.stop() } } private var talkControl: OpenClawChatTalkControl { OpenClawChatTalkControl( isEnabled: self.usesPrimaryAppRuntime && self.appState.talkEnabled, isListening: self.usesPrimaryAppRuntime && !self.talkController.isPaused && self.talkController.phase == .listening, isSpeaking: self.usesPrimaryAppRuntime && !self.talkController.isPaused && self.talkController.phase == .speaking, isGatewayConnected: self.viewModel.healthOK, statusText: self.talkStatusText, // macOS exposes live phase but not the runtime's resolved TTS provider. // An empty label avoids presenting stale config as current state. providerLabel: "", level: self.talkController.level, partialTranscript: self.talkController.partialTranscript, recentTranscript: self.talkController.recentTranscripts, inputDevices: self.audioInputCatalog.chatDevices, selectedInputDeviceID: self.appState.voiceWakeMicID.isEmpty ? nil : self.appState.voiceWakeMicID, selectInputDevice: { deviceID in self.audioInputCatalog.select(deviceID, state: self.appState) }, toggle: { sessionKey in guard self.usesPrimaryAppRuntime else { return } WebChatManager.shared.recordActiveSessionKey(sessionKey) Task { await AppStateStore.shared.setTalkEnabled(!AppStateStore.shared.talkEnabled) } }) } private var displayOptions: OpenClawChatDisplayOptions { var options: OpenClawChatDisplayOptions = [] if self.showsReasoning { options.insert(.reasoning) } if self.showsToolActivity { options.insert(.toolActivity) } return options } private var voiceNoteControl: OpenClawChatVoiceNoteControl { OpenClawChatVoiceNoteControl( recorder: self.voiceNoteRecorder, // Enabled Talk Mode owns microphone admission through teardown, // even while its visible phase is thinking or speaking. isTalkActive: self.appState.talkEnabled) } private var talkStatusText: String { guard self.usesPrimaryAppRuntime else { return String(localized: "Talk mode uses the primary Gateway window") } guard self.appState.talkEnabled else { return String(localized: "Talk mode off") } if self.talkController.isPaused { return String(localized: "Talk mode paused") } return switch self.talkController.phase { case .idle: String(localized: "Talk mode ready") case .listening: String(localized: "Listening") case .thinking: String(localized: "Thinking") case .speaking: String(localized: "Speaking") } } private static let emptyAssistantIntro = String(localized: "What would you like to work on?") private static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [ .init( id: "check-status", title: String(localized: "Check OpenClaw status"), prompt: String(localized: "Summarize the current OpenClaw status and tell me what needs attention.")), .init( id: "show-capabilities", title: String(localized: "What can you do?"), prompt: String(localized: "Show me what you can help with on this Mac right now.")), .init( id: "catch-up", title: String(localized: "Catch me up"), prompt: String(localized: "Summarize what happened in my threads since yesterday.")), ] #if DEBUG var _testCapabilities: MacChatSurfaceCapabilities { MacChatSurfaceCapabilities( hasTalkControl: true, hasSpeech: true, hasVoiceNoteControl: true, displayOptions: self.isFullWindow ? self.displayOptions : []) } #endif } #if DEBUG struct MacChatSurfaceCapabilities: Equatable { let hasTalkControl: Bool let hasSpeech: Bool let hasVoiceNoteControl: Bool let displayOptions: OpenClawChatDisplayOptions } #endif /// Bridges the view model's session switches out of the controller. The view /// model is constructed before `self`, so the closure targets this box and the /// controller re-points it after initialization. @MainActor private final class WebChatSessionKeyRelay { var onChange: ((String) -> Void)? } @MainActor final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { private let presentation: WebChatPresentation private let sessionKey: String private let initialActiveAgentID: String? private let viewModel: OpenClawChatViewModel private let contentController: NSViewController private let sessionKeyRelay: WebChatSessionKeyRelay private let speech: OpenClawChatSpeechController private let voiceNoteRecorder: OpenClawVoiceNoteRecorder private var window: NSWindow? private var dismissMonitor: Any? var onClosed: (() -> Void)? var onVisibilityChanged: ((Bool) -> Void)? /// Fires when the hosted chat switches sessions in place (sidebar, /// composer picker, /new) so the owner can track what this surface shows. var onSessionKeyChanged: ((String) -> Void)? convenience init( sessionKey: String, agentID: String? = nil, initialDraft: String? = nil, presentation: WebChatPresentation, connection: GatewayConnection = .shared, gatewayID: String? = nil, windowTitle: String = "OpenClaw Chat", windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName) { // Connection-mode changes tear chat windows down via resetTunnels(), // so binding the cache identity at construction stays correct. One // store instance backs both the transcript cache and the offline // command outbox. let context: MacChatTranscriptCache.Context? = if let gatewayID { MacChatTranscriptCache.makeContext(gatewayID: gatewayID) } else { MacChatTranscriptCache.makeContext() } self.init( sessionKey: sessionKey, agentID: agentID, initialDraft: initialDraft, presentation: presentation, connection: connection, cachedRoutingIdentity: context?.routingIdentity, store: context?.store, windowTitle: windowTitle, windowAutosaveName: windowAutosaveName) } convenience init( sessionKey: String, agentID: String?, initialDraft: String? = nil, presentation: WebChatPresentation, connection: GatewayConnection = .shared, cachedRoutingIdentity: OpenClawChatSessionRoutingIdentity?, store: OpenClawChatSQLiteTranscriptCache?, windowTitle: String = "OpenClaw Chat", windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName) { let explicitAgentID = WebChatRoute.normalizedAgentID(agentID) let effectiveAgentID = Self.effectiveAgentID( explicitAgentID: explicitAgentID, cachedDefaultAgentID: cachedRoutingIdentity?.defaultAgentID) self.init( sessionKey: sessionKey, initialDraft: initialDraft, presentation: presentation, transport: MacGatewayChatTransport( connection: connection, outboxGatewayID: store?.gatewayID, defaultGlobalAgentID: effectiveAgentID), initialActiveAgentID: effectiveAgentID, explicitAgentID: explicitAgentID, initialSessionRoutingContract: cachedRoutingIdentity?.contract, transcriptCache: store, outbox: store, windowTitle: windowTitle, windowAutosaveName: windowAutosaveName) } init( sessionKey: String, initialDraft: String? = nil, presentation: WebChatPresentation, transport: any OpenClawChatTransport, initialActiveAgentID: String? = nil, explicitAgentID: String? = nil, initialSessionRoutingContract: String? = nil, transcriptCache: (any OpenClawChatTranscriptCache)? = nil, outbox: (any OpenClawChatCommandOutbox)? = nil, windowTitle: String = "OpenClaw Chat", windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName) { self.sessionKey = sessionKey self.presentation = presentation let initialActiveAgentID = WebChatRoute.normalizedAgentID(initialActiveAgentID) self.initialActiveAgentID = initialActiveAgentID let voiceNoteRecorder = OpenClawVoiceNoteRecorder() voiceNoteRecorder.setCaptureAdmissionHandler { !AppStateStore.shared.talkEnabled } self.voiceNoteRecorder = voiceNoteRecorder let speech = OpenClawChatSpeechController { text in guard let transport = transport as? MacGatewayChatTransport else { throw MacChatMessageSpeechError.unsupportedTransport } return try await transport.synthesizeSpeech(text: text) } self.speech = speech let sessionKeyRelay = WebChatSessionKeyRelay() self.sessionKeyRelay = sessionKeyRelay let vm = OpenClawChatViewModel( sessionKey: sessionKey, transport: transport, activeAgentId: initialActiveAgentID, sessionRoutingContract: initialSessionRoutingContract, attachmentOwnerIsActive: { voiceNoteRecorder.ownsPendingChatAttachment }, transcriptCache: transcriptCache, outbox: outbox, initialThinkingLevel: Self.persistedThinkingLevel(), initialVerboseLevel: Self.persistedVerboseLevel(), onSessionChanged: { key in sessionKeyRelay.onChange?(key) }, onThinkingPreferenceChanged: { level in if let level { UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey) } else { UserDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey) } }, onVerbosePreferenceChanged: { level in Self.persistVerbosePreference(level) }) if let initialDraft, !initialDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { vm.input = initialDraft } self.viewModel = vm let explicitAgentID = WebChatRoute.normalizedAgentID(explicitAgentID) let chatConnection = (transport as? MacGatewayChatTransport)?.connection ?? .shared let usesPrimaryAppRuntime = chatConnection === GatewayConnection.shared Task { @MainActor [weak vm] in let pushes = await chatConnection.subscribe() for await push in pushes { guard let vm else { return } guard case .snapshot = push else { continue } let route = await chatConnection.captureRoute() let routingIdentity: OpenClawChatSessionRoutingIdentity? = if let route { try? await chatConnection.sessionRoutingIdentity( ifCurrentRoute: route) } else { nil } if let routingIdentity { // An explicit navigation agent owns this window; gateway // default refreshes only supply the fallback route. let effectiveAgentID = Self.effectiveAgentID( explicitAgentID: explicitAgentID, cachedDefaultAgentID: routingIdentity.defaultAgentID) (transport as? MacGatewayChatTransport)? .updateDefaultGlobalAgentID(effectiveAgentID) if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache, !usesPrimaryAppRuntime || store.gatewayID == MacChatTranscriptCache.currentGatewayID(), let persistedIdentity = OpenClawChatSessionRoutingIdentity( contract: routingIdentity.contract) { await store.storeSessionRoutingIdentity(persistedIdentity) } vm.syncDeliveryIdentity( activeAgentId: effectiveAgentID, sessionRoutingContract: routingIdentity.contract) } } } let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) switch presentation { case .window: // Full window: native split-view shell with sessions sidebar and // toolbar pickers bridged into the NSToolbar. let hosting = NSHostingController(rootView: MacChatSurface( viewModel: vm, isFullWindow: true, userAccent: accent, usesPrimaryAppRuntime: usesPrimaryAppRuntime, speech: speech, voiceNoteRecorder: voiceNoteRecorder)) self.contentController = hosting case .panel: // Anchored compact chat panel: single-column chat. let hosting = NSHostingController(rootView: MacChatSurface( viewModel: vm, isFullWindow: false, userAccent: accent, usesPrimaryAppRuntime: usesPrimaryAppRuntime, speech: speech, voiceNoteRecorder: voiceNoteRecorder)) self.contentController = Self.makePanelContentController(hosting: hosting) } super.init() self.window = Self.makeWindow( for: presentation, contentViewController: self.contentController, title: windowTitle, autosaveName: windowAutosaveName) self.window?.delegate = self sessionKeyRelay.onChange = { [weak self] key in self?.onSessionKeyChanged?(key) } } deinit {} var isVisible: Bool { self.window?.isVisible ?? false } func applyDraftIfEmpty(_ draft: String?) { guard self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let draft, !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } self.viewModel.input = draft } func show() { guard let window else { return } self.ensureWindowSize() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) self.onVisibilityChanged?(true) } func cascade(from source: WebChatSwiftUIWindowController?) { guard case .window = self.presentation, let window, let sourceWindow = source?.window, sourceWindow !== window else { return } let bounds = sourceWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero window.setFrame( WindowPlacement.cascadedFrame(from: sourceWindow.frame, in: bounds), display: false) } func presentAnchored(anchorProvider: () -> NSRect?) { guard case .panel = self.presentation, let window else { return } self.installDismissMonitor() let target = self.reposition(using: anchorProvider) if !self.isVisible { let start = target.offsetBy(dx: 0, dy: 8) window.setFrame(start, display: true) window.alphaValue = 0 window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeOut) window.animator().setFrame(target, display: true) window.animator().alphaValue = 1 } } else { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } self.onVisibilityChanged?(true) } func close() { switch self.presentation { case .window: self.window?.close() case .panel: self.window?.orderOut(nil) self.onVisibilityChanged?(false) self.onClosed?() self.removeDismissMonitor() } } func windowWillClose(_ notification: Notification) { guard case .window = self.presentation, notification.object as? NSWindow === self.window else { return } self.onVisibilityChanged?(false) self.removeDismissMonitor() let onClosed = self.onClosed self.onClosed = nil self.window = nil onClosed?() } @discardableResult private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { guard let window else { return .zero } guard let anchor = anchorProvider() else { let frame = WindowPlacement.topRightFrame( size: WebChatSwiftUILayout.panelSize, padding: WebChatSwiftUILayout.anchorPadding) window.setFrame(frame, display: false) return frame } let screen = NSScreen.screens.first { screen in screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) } ?? NSScreen.main let bounds = (screen?.visibleFrame ?? .zero).insetBy( dx: WebChatSwiftUILayout.anchorPadding, dy: WebChatSwiftUILayout.anchorPadding) let frame = WindowPlacement.anchoredBelowFrame( size: WebChatSwiftUILayout.panelSize, anchor: anchor, padding: WebChatSwiftUILayout.anchorPadding, in: bounds) window.setFrame(frame, display: false) return frame } private func installDismissMonitor() { if ProcessInfo.processInfo.isRunningTests { return } guard self.dismissMonitor == nil, self.window != nil else { return } self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in guard let self, let win = self.window else { return } let pt = NSEvent.mouseLocation if !win.frame.contains(pt) { self.close() } } } private func removeDismissMonitor() { OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor) } static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? { let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard let stored, ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"].contains(stored) else { return nil } return stored } static func persistedVerboseLevel(defaults: UserDefaults = .standard) -> String? { let stored = defaults.string(forKey: webChatVerboseLevelDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return OpenClawChatViewModel.verboseLevelOptions.contains(stored ?? "") ? stored : nil } static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = .standard) { if let level { defaults.set(level, forKey: webChatVerboseLevelDefaultsKey) } else { defaults.removeObject(forKey: webChatVerboseLevelDefaultsKey) } } static func effectiveAgentID( explicitAgentID: String?, cachedDefaultAgentID: String?) -> String? { WebChatRoute.normalizedAgentID(explicitAgentID) ?? WebChatRoute.normalizedAgentID(cachedDefaultAgentID) } private static func makeWindow( for presentation: WebChatPresentation, contentViewController: NSViewController, title: String, autosaveName: String) -> NSWindow { switch presentation { case .window: let window = WebChatWindow( contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false) window.title = title window.pinnedTitle = title window.contentViewController = contentViewController // Attaching an NSHostingController resets scene bridging to `.all`; // opt back into toolbar items only so SwiftUI cannot restore the title. (contentViewController as? NSHostingController)? .sceneBridgingOptions = [.toolbars] window.isReleasedWhenClosed = false window.isRestorable = false // Keep the SwiftUI toolbar controls, but merge their unified row // with the traffic lights instead of stacking it below a title band. window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.toolbarStyle = .unified window.titlebarSeparatorStyle = .none window.isMovableByWindowBackground = true window.center() window.setFrameAutosaveName(autosaveName) WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) window.minSize = WebChatSwiftUILayout.windowMinSize return window case .panel: let panel = WebChatPanel( contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), styleMask: [.borderless], backing: .buffered, defer: false) panel.level = .statusBar panel.hidesOnDeactivate = true panel.hasShadow = true panel.isMovable = false panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true panel.backgroundColor = .clear panel.isOpaque = false panel.contentViewController = contentViewController panel.becomesKeyOnlyIfNeeded = true panel.contentView?.wantsLayer = true panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor panel.setFrame( WindowPlacement.topRightFrame( size: WebChatSwiftUILayout.panelSize, padding: WebChatSwiftUILayout.anchorPadding), display: false) return panel } } private static func makePanelContentController( hosting: NSHostingController) -> NSViewController { let controller = NSViewController() let effectView = NSVisualEffectView() effectView.material = .sidebar effectView.blendingMode = .withinWindow effectView.state = .active effectView.wantsLayer = true effectView.layer?.cornerCurve = .continuous let cornerRadius: CGFloat = 16 effectView.layer?.cornerRadius = cornerRadius effectView.layer?.masksToBounds = true effectView.layer?.backgroundColor = NSColor.clear.cgColor effectView.translatesAutoresizingMaskIntoConstraints = true effectView.autoresizingMask = [.width, .height] let rootView = effectView hosting.view.translatesAutoresizingMaskIntoConstraints = false hosting.view.wantsLayer = true hosting.view.layer?.cornerCurve = .continuous hosting.view.layer?.cornerRadius = cornerRadius hosting.view.layer?.masksToBounds = true hosting.view.layer?.backgroundColor = NSColor.clear.cgColor controller.addChild(hosting) effectView.addSubview(hosting.view) controller.view = rootView NSLayoutConstraint.activate([ hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), ]) return controller } private func ensureWindowSize() { guard case .window = self.presentation, let window else { return } let current = window.frame.size let min = WebChatSwiftUILayout.windowMinSize if current.width < min.width || current.height < min.height { let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) window.setFrame(frame, display: false) } } private static func color(fromHex raw: String?) -> Color? { ColorHexSupport.color(fromHex: raw) } #if DEBUG var _testWindow: NSWindow? { self.window } var _testSceneBridgingOptions: NSHostingSceneBridgingOptions? { (self.contentController as? NSHostingController)?.sceneBridgingOptions } var _testChatCapabilities: MacChatSurfaceCapabilities? { if let hosting = contentController as? NSHostingController { return hosting.rootView._testCapabilities } return self.contentController.children .compactMap { $0 as? NSHostingController } .first?.rootView._testCapabilities } var _testActiveAgentID: String? { self.initialActiveAgentID } var _testDraft: String { self.viewModel.input } #endif }