This commit is contained in:
Alex Heron 2026-01-30 18:55:41 +05:30 committed by GitHub
commit f03a6d30d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 117 additions and 13 deletions

View File

@ -8,7 +8,7 @@ struct ChatSheet: View {
private let userAccent: Color?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway)
let transport = IOSGatewayChatTransport(gateway: gateway, supportsChatSubscribe: false)
self._viewModel = State(
initialValue: OpenClawChatViewModel(
sessionKey: sessionKey,

View File

@ -5,9 +5,11 @@ import Foundation
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
private let gateway: GatewayNodeSession
private let supportsChatSubscribe: Bool
init(gateway: GatewayNodeSession) {
init(gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) {
self.gateway = gateway
self.supportsChatSubscribe = supportsChatSubscribe
}
func abortRun(sessionKey: String, runId: String) async throws {
@ -33,6 +35,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
guard self.supportsChatSubscribe else { return }
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)

View File

@ -205,7 +205,8 @@ final class GatewayConnectionController {
password: String?)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
let nodeConnectOptions = self.makeNodeConnectOptions()
let operatorConnectOptions = self.makeOperatorConnectOptions()
Task { [weak self] in
guard let self else { return }
@ -218,7 +219,8 @@ final class GatewayConnectionController {
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
nodeConnectOptions: nodeConnectOptions,
operatorConnectOptions: operatorConnectOptions)
}
}
@ -273,7 +275,7 @@ final class GatewayConnectionController {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions() -> GatewayConnectOptions {
private func makeNodeConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
@ -288,6 +290,21 @@ final class GatewayConnectionController {
clientDisplayName: displayName)
}
private func makeOperatorConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
return GatewayConnectOptions(
role: "operator",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "clawdbot-ios",
clientMode: "ui",
clientDisplayName: displayName)
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View File

@ -19,6 +19,7 @@ final class NodeAppModel {
let camera = CameraController()
private let screenRecorder = ScreenRecordService()
var gatewayStatusText: String = "Offline"
var operatorGatewayStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@ -26,7 +27,9 @@ final class NodeAppModel {
var mainSessionKey: String = "main"
private let gateway = GatewayNodeSession()
private let operatorGateway = GatewayNodeSession()
private var gatewayTask: Task<Void, Never>?
private var operatorGatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
@ -36,6 +39,7 @@ final class NodeAppModel {
private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway }
var operatorGatewaySession: GatewayNodeSession { self.operatorGateway }
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@ -55,7 +59,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.gateway)
self.talkMode.attachGateway(self.operatorGateway, supportsChatSubscribe: false)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled)
@ -209,9 +213,13 @@ final class NodeAppModel {
tls: GatewayTLSParams?,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions)
nodeConnectOptions: GatewayConnectOptions,
operatorConnectOptions: GatewayConnectOptions)
{
self.gatewayTask?.cancel()
self.gatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
@ -219,7 +227,8 @@ final class NodeAppModel {
self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
let nodeSessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
let operatorSessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.gatewayTask = Task {
var attempt = 0
@ -239,8 +248,8 @@ final class NodeAppModel {
url: url,
token: token,
password: password,
connectOptions: connectOptions,
sessionBox: sessionBox,
connectOptions: nodeConnectOptions,
sessionBox: nodeSessionBox,
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
@ -299,6 +308,7 @@ final class NodeAppModel {
await MainActor.run {
self.gatewayStatusText = "Offline"
self.operatorGatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
@ -311,15 +321,75 @@ final class NodeAppModel {
self.showLocalCanvasOnDisconnect()
}
}
self.operatorGatewayTask = Task {
var attempt = 0
while !Task.isCancelled {
await MainActor.run {
if attempt == 0 {
self.operatorGatewayStatusText = "Connecting…"
} else {
self.operatorGatewayStatusText = "Reconnecting…"
}
}
do {
try await self.operatorGateway.connect(
url: url,
token: token,
password: password,
connectOptions: operatorConnectOptions,
sessionBox: operatorSessionBox,
onConnected: { [weak self] in
await MainActor.run {
self?.operatorGatewayStatusText = "Connected"
}
},
onDisconnected: { [weak self] reason in
await MainActor.run {
self?.operatorGatewayStatusText = "Disconnected: \(reason)"
}
},
onInvoke: { req in
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "UNAVAILABLE: operator session does not handle invokes"))
})
if Task.isCancelled { break }
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.operatorGatewayStatusText = "Gateway error: \(error.localizedDescription)"
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
await MainActor.run {
self.operatorGatewayStatusText = "Offline"
}
}
}
func disconnectGateway() {
self.gatewayTask?.cancel()
self.gatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
Task { await self.gateway.disconnect() }
Task { await self.operatorGateway.disconnect() }
self.gatewayStatusText = "Offline"
self.operatorGatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil

View File

@ -52,7 +52,7 @@ struct RootCanvas: View {
SettingsTab()
case .chat:
ChatSheet(
gateway: self.appModel.gatewaySession,
gateway: self.appModel.operatorGatewaySession,
sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor)
}

View File

@ -67,6 +67,7 @@ struct SettingsTab: View {
Section("Gateway") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Operator Status", value: self.appModel.operatorGatewayStatusText)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {

View File

@ -44,14 +44,16 @@ final class TalkModeManager: NSObject {
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var gateway: GatewayNodeSession?
private var supportsChatSubscribe: Bool = true
private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
func attachGateway(_ gateway: GatewayNodeSession) {
func attachGateway(_ gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) {
self.gateway = gateway
self.supportsChatSubscribe = supportsChatSubscribe
}
func updateMainSessionKey(_ sessionKey: String?) {
@ -296,6 +298,7 @@ final class TalkModeManager: NSObject {
}
private func subscribeChatIfNeeded(sessionKey: String) async {
guard self.supportsChatSubscribe else { return }
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let gateway else { return }
@ -308,6 +311,7 @@ final class TalkModeManager: NSObject {
}
private func unsubscribeAllChats() async {
guard self.supportsChatSubscribe else { return }
guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()

View File

@ -33,7 +33,16 @@ const APPROVALS_SCOPE = "operator.approvals";
const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
const NODE_ROLE_METHODS = new Set([
"node.invoke.result",
"node.event",
"skills.bins",
// Allow node role to access chat for iOS/mobile chat UI
"chat.history",
"chat.send",
"chat.abort",
"sessions.list",
]);
const PAIRING_METHODS = new Set([
"node.pair.request",
"node.pair.list",