Merge 299752dde5 into da71eaebd2
This commit is contained in:
commit
f03a6d30d0
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) ?? ""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user