mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 12:11:40 +00:00
* feat(apple): complete media playback and uploads * fix(apple): preserve movie picker metadata * fix(apple): harden rendition and picker routing * style(apple): fix macOS media formatting
809 lines
33 KiB
Swift
809 lines
33 KiB
Swift
import Foundation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import OSLog
|
|
|
|
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
|
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
|
|
private let gateway: GatewayNodeSession
|
|
private let widgetGateway: GatewayNodeSession?
|
|
private let globalAgentId: String?
|
|
private let outboxGatewayID: String?
|
|
private let sessionMutationRequest: (@Sendable (OpenClawChatGatewayRequest) async throws -> Data)?
|
|
private let mediaArtifactLoader: IOSMediaArtifactLoader?
|
|
|
|
var outboxRequiresSessionRoutingContract: Bool {
|
|
true
|
|
}
|
|
|
|
init(
|
|
gateway: GatewayNodeSession,
|
|
widgetGateway: GatewayNodeSession? = nil,
|
|
globalAgentId: String? = nil,
|
|
outboxGatewayID: String? = nil,
|
|
sessionMutationRequest: (@Sendable (OpenClawChatGatewayRequest) async throws -> Data)? = nil,
|
|
mediaArtifactLoader: IOSMediaArtifactLoader? = nil)
|
|
{
|
|
self.gateway = gateway
|
|
self.widgetGateway = widgetGateway
|
|
let normalized = globalAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
self.globalAgentId = normalized?.isEmpty == false ? normalized : nil
|
|
let normalizedGatewayID = outboxGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.outboxGatewayID = normalizedGatewayID?.isEmpty == false ? normalizedGatewayID : nil
|
|
self.sessionMutationRequest = sessionMutationRequest
|
|
self.mediaArtifactLoader = mediaArtifactLoader
|
|
}
|
|
|
|
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
|
|
guard let outboxGatewayID,
|
|
let route = await gateway.currentRoute(ifGatewayID: outboxGatewayID)
|
|
else { return .unavailable(reason: nil) }
|
|
guard let supportsRoutingContract = await gateway.supportsServerCapability(
|
|
.chatSendRoutingContract,
|
|
ifCurrentRoute: route)
|
|
else { return .unavailable(reason: nil) }
|
|
guard supportsRoutingContract else {
|
|
return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract)
|
|
}
|
|
let transport = self
|
|
guard let routingContract = try? await transport.sessionRoutingContract(ifCurrentRoute: route)
|
|
else { return .unavailable(reason: nil) }
|
|
return .available(OpenClawChatTransportRouteLease(
|
|
sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
|
|
try await transport.sendMessage(
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
expectedSessionRoutingContract: routingContract,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments,
|
|
ifCurrentRoute: route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
},
|
|
requestTargetedHistory: { sessionKey, agentID in
|
|
try await transport.requestHistory(
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
ifCurrentRoute: route)
|
|
},
|
|
sessionRoutingContract: routingContract))
|
|
}
|
|
|
|
func acquireSwarmRouteLease() async -> OpenClawChatSwarmRouteLease? {
|
|
guard let route = await self.currentSessionMutationRoute() else { return nil }
|
|
let transport = self
|
|
return OpenClawChatSwarmRouteLease(
|
|
isEnabled: { sessionKey in
|
|
try await transport.isSwarmEnabled(sessionKey: sessionKey, ifCurrentRoute: route)
|
|
},
|
|
listChildSessions: { parentKey in
|
|
try await transport.listChildSessions(parentKey: parentKey, ifCurrentRoute: route)
|
|
})
|
|
}
|
|
|
|
func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? {
|
|
let route = await currentSessionMutationRoute()
|
|
guard let route else { return nil }
|
|
let transport = self
|
|
return OpenClawChatSessionSettingsRouteLease { sessionKey, agentID, patch in
|
|
try await transport.patchSessionSettings(
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
patch: patch,
|
|
ifCurrentRoute: route)
|
|
}
|
|
}
|
|
|
|
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease? {
|
|
guard let route = await currentSessionMutationRoute() else { return nil }
|
|
let transport = self
|
|
return OpenClawChatSessionMutationRouteLease(
|
|
patchSession: { key, label, category, pinned, archived, unread in
|
|
let target = transport.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 transport.requestSessionMutation(request, ifCurrentRoute: route)
|
|
},
|
|
deleteSession: { key in
|
|
let target = transport.sessionTarget(for: key)
|
|
let request = OpenClawChatGatewayRequests.deleteSession(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID)
|
|
_ = try await transport.requestSessionMutation(request, ifCurrentRoute: route)
|
|
})
|
|
}
|
|
|
|
func acquireSessionGroupsRouteLease() async -> OpenClawChatSessionGroupsRouteLease? {
|
|
guard let route = await currentSessionMutationRoute() else { return nil }
|
|
let transport = self
|
|
return Self.makeSessionGroupsRouteLease { request in
|
|
try await transport.requestSessionMutation(request, ifCurrentRoute: route)
|
|
}
|
|
}
|
|
|
|
func acquireNewSessionRouteLease() async -> OpenClawChatNewSessionRouteLease? {
|
|
guard let route = await currentSessionMutationRoute() else { return nil }
|
|
let transport = self
|
|
let request: @Sendable (OpenClawChatGatewayRequest) async throws -> Data = { request in
|
|
try await transport.requestSessionMutation(request, ifCurrentRoute: route)
|
|
}
|
|
return OpenClawChatNewSessionRouteLease(
|
|
listAgents: {
|
|
let data = try await 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)
|
|
})
|
|
},
|
|
createSession: { key, label, agentID, parentSessionKey, worktree, worktreeBaseRef in
|
|
let createRequest = transport.createSessionRequest(
|
|
key: key,
|
|
label: label,
|
|
agentID: agentID,
|
|
parentSessionKey: parentSessionKey,
|
|
worktree: worktree,
|
|
worktreeBaseRef: worktreeBaseRef)
|
|
let data = try await request(createRequest)
|
|
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data)
|
|
})
|
|
}
|
|
|
|
private func currentSessionMutationRoute() async -> GatewayNodeSessionRoute? {
|
|
if let outboxGatewayID {
|
|
return await self.gateway.currentRoute(ifGatewayID: outboxGatewayID)
|
|
}
|
|
return await self.gateway.currentRoute()
|
|
}
|
|
|
|
private func sessionRoutingContract(
|
|
ifCurrentRoute route: GatewayNodeSessionRoute) async throws -> String
|
|
{
|
|
let data = try await gateway.request(
|
|
OpenClawChatGatewayRequests.agentsList(),
|
|
ifCurrentRoute: route)
|
|
return try OpenClawChatGatewayPayloadCodec.decodeSessionRoutingIdentity(data).contract
|
|
}
|
|
|
|
typealias SessionTarget = OpenClawChatSessionTarget
|
|
|
|
static func sessionTarget(
|
|
for rawSessionKey: String,
|
|
selectedAgentID: String?,
|
|
overrideAgentID: String? = nil) -> SessionTarget
|
|
{
|
|
OpenClawChatSessionTarget.resolve(
|
|
rawSessionKey,
|
|
selectedAgentID: selectedAgentID,
|
|
overrideAgentID: overrideAgentID,
|
|
policy: .scopeBareKeysToSelectedAgent)
|
|
}
|
|
|
|
private func sessionTarget(
|
|
for sessionKey: String,
|
|
overrideAgentID: String? = nil) -> SessionTarget
|
|
{
|
|
Self.sessionTarget(
|
|
for: sessionKey,
|
|
selectedAgentID: self.globalAgentId,
|
|
overrideAgentID: overrideAgentID)
|
|
}
|
|
|
|
private func requestSessionMutation(_ request: OpenClawChatGatewayRequest) async throws -> Data {
|
|
if let sessionMutationRequest {
|
|
return try await sessionMutationRequest(request)
|
|
}
|
|
return try await self.gateway.request(request)
|
|
}
|
|
|
|
private func requestSessionMutation(
|
|
_ request: OpenClawChatGatewayRequest,
|
|
ifCurrentRoute route: GatewayNodeSessionRoute) async throws -> Data
|
|
{
|
|
try await self.gateway.request(
|
|
request,
|
|
ifCurrentRoute: route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
}
|
|
|
|
static func makeSessionGroupsRouteLease(
|
|
request: @escaping @Sendable (OpenClawChatGatewayRequest) async throws -> Data)
|
|
-> OpenClawChatSessionGroupsRouteLease
|
|
{
|
|
OpenClawChatSessionGroupsRouteLease(
|
|
listGroups: {
|
|
let data = try await request(OpenClawChatGatewayRequests.sessionGroupsList())
|
|
return try JSONDecoder().decode(OpenClawChatSessionGroupsResponse.self, from: data)
|
|
},
|
|
putGroups: { names in
|
|
let data = try await request(OpenClawChatGatewayRequests.sessionGroupsPut(names: names))
|
|
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
|
},
|
|
renameGroup: { name, to in
|
|
let data = try await request(OpenClawChatGatewayRequests.sessionGroupsRename(name: name, to: to))
|
|
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
|
},
|
|
deleteGroup: { name in
|
|
let data = try await request(OpenClawChatGatewayRequests.sessionGroupsDelete(name: name))
|
|
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
|
})
|
|
}
|
|
|
|
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: String?,
|
|
parentSessionKey: String?,
|
|
worktree: Bool?,
|
|
worktreeBaseRef: String?) async throws -> OpenClawChatCreateSessionResponse
|
|
{
|
|
let request = self.createSessionRequest(
|
|
key: key,
|
|
label: label,
|
|
agentID: agentID,
|
|
parentSessionKey: parentSessionKey,
|
|
worktree: worktree,
|
|
worktreeBaseRef: worktreeBaseRef)
|
|
let res = try await requestSessionMutation(request)
|
|
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
|
|
}
|
|
|
|
private func createSessionRequest(
|
|
key: String,
|
|
label: String?,
|
|
agentID: String?,
|
|
parentSessionKey: String?,
|
|
worktree: Bool?,
|
|
worktreeBaseRef: String?) -> OpenClawChatGatewayRequest
|
|
{
|
|
let target = self.sessionTarget(for: key, overrideAgentID: agentID)
|
|
let parentTarget = parentSessionKey.map { self.sessionTarget(for: $0) }
|
|
let explicitAgentID = agentID?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
return OpenClawChatGatewayRequests.createSession(
|
|
key: target.sessionKey,
|
|
agentID: explicitAgentID?.isEmpty == false
|
|
? explicitAgentID
|
|
: target.agentID ?? parentTarget?.agentID,
|
|
label: label,
|
|
parentSessionKey: parentTarget?.sessionKey,
|
|
worktree: worktree,
|
|
worktreeBaseRef: worktreeBaseRef)
|
|
}
|
|
|
|
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.gateway.request(request)
|
|
}
|
|
|
|
func listSessions(
|
|
limit: Int?,
|
|
search: String?,
|
|
archived: Bool) async throws -> OpenClawChatSessionsListResponse
|
|
{
|
|
let request = OpenClawChatGatewayRequests.sessionsList(
|
|
limit: limit,
|
|
search: search,
|
|
archived: archived)
|
|
let res = try await gateway.request(request)
|
|
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
|
|
}
|
|
|
|
func listChildSessions(parentKey: String) async throws -> [OpenClawChatSessionEntry] {
|
|
try await self.listChildSessions(parentKey: parentKey, ifCurrentRoute: nil)
|
|
}
|
|
|
|
private func listChildSessions(
|
|
parentKey: String,
|
|
ifCurrentRoute route: GatewayNodeSessionRoute?) 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 = try await gateway.request(request, ifCurrentRoute: route)
|
|
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
|
}
|
|
}
|
|
|
|
func listModels() async throws -> [OpenClawChatModelChoice] {
|
|
let response = try await gateway.request(OpenClawChatGatewayRequests.modelsList())
|
|
return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(response)
|
|
}
|
|
|
|
func isSwarmEnabled(sessionKey: String) async throws -> Bool {
|
|
try await self.isSwarmEnabled(sessionKey: sessionKey, ifCurrentRoute: nil)
|
|
}
|
|
|
|
private func isSwarmEnabled(
|
|
sessionKey: String,
|
|
ifCurrentRoute route: GatewayNodeSessionRoute?) async throws -> Bool
|
|
{
|
|
let request = OpenClawChatGatewayRequests.chatMetadata(
|
|
sessionKey: sessionKey,
|
|
fallbackAgentID: self.globalAgentId)
|
|
let response = try await gateway.request(request, ifCurrentRoute: route)
|
|
return try JSONDecoder().decode(OpenClawChatMetadataCapabilities.self, from: response).swarmEnabled
|
|
}
|
|
|
|
func setSessionModel(sessionKey: String, model: String?) async throws {
|
|
_ = try await self.patchSessionModel(sessionKey: sessionKey, agentID: nil, 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,
|
|
ifCurrentRoute: nil)
|
|
}
|
|
|
|
private func patchSessionSettings(
|
|
sessionKey: String,
|
|
agentID: String?,
|
|
patch: OpenClawChatSessionSettingsPatch,
|
|
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatModelPatchResult?
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
|
let request = OpenClawChatGatewayRequests.patchSessionSettings(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
model: patch.model,
|
|
thinkingLevel: patch.thinkingLevel,
|
|
fastMode: patch.fastMode,
|
|
verboseLevel: patch.verboseLevel)
|
|
let response = if let expectedRoute {
|
|
try await self.gateway.request(
|
|
request,
|
|
ifCurrentRoute: expectedRoute,
|
|
distinguishPreDispatchRouteChange: true)
|
|
} else {
|
|
try await self.requestSessionMutation(request)
|
|
}
|
|
return try Self.decodeModelPatchResult(response)
|
|
}
|
|
|
|
static func decodeModelPatchResult(_ data: Data) throws -> OpenClawChatModelPatchResult {
|
|
try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data)
|
|
}
|
|
|
|
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 patchSession(
|
|
key: String,
|
|
label: String?? = nil,
|
|
category: String?? = nil,
|
|
pinned: Bool? = nil,
|
|
archived: Bool? = nil,
|
|
unread: Bool? = nil) 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.requestSessionMutation(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.requestSessionMutation(request)
|
|
}
|
|
|
|
func forkSession(parentKey: String) async throws -> String {
|
|
let target = self.sessionTarget(for: parentKey)
|
|
let childAgentID = target.agentID ?? OpenClawChatSessionKey.agentID(from: target.sessionKey)
|
|
let request = OpenClawChatGatewayRequests.forkSession(
|
|
parentSessionKey: target.sessionKey,
|
|
agentID: childAgentID)
|
|
let response = try await requestSessionMutation(request)
|
|
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: response).key
|
|
}
|
|
|
|
func rewindSession(
|
|
sessionKey: String,
|
|
entryId: String) async throws -> OpenClawChatRewindResponse
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey)
|
|
let request = OpenClawChatGatewayRequests.rewindSession(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
entryId: entryId)
|
|
let response = try await requestSessionMutation(request)
|
|
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: response)
|
|
}
|
|
|
|
func forkSessionAtMessage(
|
|
sessionKey: String,
|
|
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey)
|
|
let request = OpenClawChatGatewayRequests.forkAtMessage(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
entryId: entryId)
|
|
let response = try await requestSessionMutation(request)
|
|
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: response)
|
|
}
|
|
|
|
func listSessionBranches(
|
|
sessionKey: String,
|
|
agentID: String?) async throws -> OpenClawChatSessionBranchesResponse
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
|
let request = OpenClawChatGatewayRequests.listSessionBranches(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID)
|
|
let response = try await gateway.request(request)
|
|
return try JSONDecoder().decode(OpenClawChatSessionBranchesResponse.self, from: response)
|
|
}
|
|
|
|
func switchSessionBranch(sessionKey: String, agentID: String?, leafEntryId: String) async throws {
|
|
let target = self.sessionTarget(for: sessionKey)
|
|
let request = OpenClawChatGatewayRequests.switchSessionBranch(
|
|
sessionKey: target.sessionKey,
|
|
agentID: agentID ?? target.agentID,
|
|
leafEntryId: leafEntryId)
|
|
_ = try await self.requestSessionMutation(request)
|
|
}
|
|
|
|
func setActiveSessionKey(_ sessionKey: String) async throws {
|
|
let target = self.sessionTarget(for: sessionKey)
|
|
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID)
|
|
_ = try await self.gateway.request(request)
|
|
}
|
|
|
|
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.gateway.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 gateway.request(request)
|
|
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
|
|
}
|
|
|
|
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
|
try await self.requestHistory(sessionKey: sessionKey, agentID: nil, ifCurrentRoute: nil)
|
|
}
|
|
|
|
func resolveInlineWidgetResource(
|
|
path: String,
|
|
replacing failedResource: OpenClawChatWidgetResource?) async -> OpenClawChatWidgetResource?
|
|
{
|
|
let gateway = self.gateway
|
|
let widgetGateway = self.widgetGateway
|
|
return await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: path,
|
|
replacing: failedResource,
|
|
currentSurfaceRoutes: {
|
|
let node = await widgetGateway?.currentCanvasHostRoute()
|
|
let operatorSurface = await gateway.currentCanvasHostRoute()
|
|
return (node: node, operatorSurface: operatorSurface)
|
|
},
|
|
// Prefer the device's node route; operator rotation covers clients
|
|
// whose node role is unavailable or intentionally disabled.
|
|
refreshNodeSurfaceRoute: { observed in
|
|
await widgetGateway?.refreshCanvasHostRoute(replacing: observed?.url)
|
|
},
|
|
refreshOperatorSurfaceRoute: { observed in
|
|
await gateway.refreshCanvasHostRoute(replacing: observed?.url)
|
|
})
|
|
}
|
|
|
|
func loadMediaArtifact(
|
|
sessionKey: String,
|
|
artifactId: String,
|
|
kind: OpenClawChatMediaKind,
|
|
playback: OpenClawChatPlaybackMode?) async throws -> OpenClawChatLoadedMedia?
|
|
{
|
|
guard kind.acceptsManagedArtifactID(artifactId),
|
|
let mediaArtifactLoader,
|
|
let route = await gateway.currentRoute(),
|
|
let gatewayID = await gateway.currentGatewayID(ifCurrentRoute: route)
|
|
else { return nil }
|
|
let target = self.sessionTarget(for: sessionKey)
|
|
let request = OpenClawChatGatewayRequests.artifactDownload(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
artifactId: artifactId)
|
|
let data = try await gateway.request(request, ifCurrentRoute: route)
|
|
let response = try JSONDecoder().decode(ArtifactsDownloadResult.self, from: data)
|
|
guard await self.gateway.currentRoute() == route else { throw CancellationError() }
|
|
let loaded = try await mediaArtifactLoader.load(
|
|
response: response,
|
|
kind: kind,
|
|
playback: playback,
|
|
expectedGatewayID: gatewayID)
|
|
guard await self.gateway.currentRoute() == route else { throw CancellationError() }
|
|
return loaded
|
|
}
|
|
|
|
func resolveInlineWidgetURL(path: String, replacing failedURL: URL?) async -> URL? {
|
|
await self.resolveInlineWidgetResource(
|
|
path: path,
|
|
replacing: failedURL.map { OpenClawChatWidgetResource(url: $0) })?.url
|
|
}
|
|
|
|
func requestHistory(
|
|
sessionKey: String,
|
|
agentID: String? = nil,
|
|
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
|
let request = OpenClawChatGatewayRequests.history(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID)
|
|
let res = try await gateway.request(
|
|
request,
|
|
ifCurrentRoute: expectedRoute)
|
|
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
|
}
|
|
|
|
var supportsSlashCommandCatalog: Bool {
|
|
true
|
|
}
|
|
|
|
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
|
|
let request = OpenClawChatGatewayRequests.commandsList(
|
|
sessionKey: sessionKey,
|
|
fallbackAgentID: self.globalAgentId)
|
|
let res = try await gateway.request(request)
|
|
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res)
|
|
return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice)
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey: String,
|
|
message: String,
|
|
thinking: String,
|
|
idempotencyKey: String,
|
|
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
|
{
|
|
try await self.sendMessage(
|
|
sessionKey: sessionKey,
|
|
agentID: nil,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments,
|
|
ifCurrentRoute: nil)
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey: String,
|
|
agentID: String?,
|
|
expectedSessionRoutingContract: String?,
|
|
message: String,
|
|
thinking: String,
|
|
idempotencyKey: String,
|
|
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
|
{
|
|
let route: GatewayNodeSessionRoute? = if let outboxGatewayID {
|
|
await self.gateway.currentRoute(ifGatewayID: outboxGatewayID)
|
|
} else {
|
|
await self.gateway.currentRoute()
|
|
}
|
|
guard let route,
|
|
let supportsRoutingContract = await gateway.supportsServerCapability(
|
|
.chatSendRoutingContract,
|
|
ifCurrentRoute: route)
|
|
else { throw OpenClawChatTransportSendError.notDispatched }
|
|
// Durable replay requires the atomic server guard and is blocked in
|
|
// acquireOutboxRouteLease. Keep ordinary live chat compatible with
|
|
// older gateways by retaining the captured route but omitting the
|
|
// unsupported request field.
|
|
let guardedContract = OpenClawChatSessionRoutingContract.expectedValue(
|
|
expectedSessionRoutingContract,
|
|
serverSupportsGuard: supportsRoutingContract)
|
|
return try await self.sendMessage(
|
|
sessionKey: sessionKey,
|
|
agentID: agentID,
|
|
expectedSessionRoutingContract: guardedContract,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments,
|
|
ifCurrentRoute: route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
}
|
|
|
|
func sendMessage(
|
|
sessionKey: String,
|
|
agentID: String? = nil,
|
|
expectedSessionRoutingContract: String? = nil,
|
|
message: String,
|
|
thinking: String?,
|
|
idempotencyKey: String,
|
|
attachments: [OpenClawChatAttachmentPayload],
|
|
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?,
|
|
distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse
|
|
{
|
|
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
|
let startLogMessage =
|
|
"chat.send start sessionKey=\(target.sessionKey) "
|
|
+ "len=\(message.count) attachments=\(attachments.count)"
|
|
Self.logger.info(
|
|
"\(startLogMessage, privacy: .public)")
|
|
GatewayDiagnostics.log(startLogMessage)
|
|
let request = OpenClawChatGatewayRequests.sendMessage(
|
|
sessionKey: target.sessionKey,
|
|
agentID: target.agentID,
|
|
expectedSessionRoutingContract: expectedSessionRoutingContract,
|
|
message: message,
|
|
thinking: thinking,
|
|
idempotencyKey: idempotencyKey,
|
|
attachments: attachments)
|
|
do {
|
|
let res = try await gateway.request(
|
|
request,
|
|
ifCurrentRoute: expectedRoute,
|
|
distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange)
|
|
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
|
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
|
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
|
|
return decoded
|
|
} catch is GatewayNodeSessionRequestError {
|
|
Self.logger.info("chat.send skipped because the captured route changed before dispatch")
|
|
GatewayDiagnostics.log("chat.send skipped before dispatch: route changed")
|
|
throw OpenClawChatTransportSendError.notDispatched
|
|
} catch {
|
|
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
|
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func waitForRunCompletion(
|
|
runId rawRunId: String,
|
|
timeoutMs: Int) async -> OpenClawChatRunObservation
|
|
{
|
|
let route = await gateway.currentRoute()
|
|
return await self.waitForRunCompletion(
|
|
runId: rawRunId,
|
|
timeoutMs: timeoutMs,
|
|
ifCurrentRoute: route)
|
|
}
|
|
|
|
func waitForRunCompletion(
|
|
runId rawRunId: String,
|
|
timeoutMs: Int,
|
|
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async -> OpenClawChatRunObservation
|
|
{
|
|
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !runId.isEmpty, let expectedRoute else { return .unavailable }
|
|
|
|
do {
|
|
let request = OpenClawChatGatewayRequests.agentWait(runID: runId, timeoutMs: timeoutMs)
|
|
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
|
|
let res = try await gateway.request(
|
|
request,
|
|
ifCurrentRoute: expectedRoute)
|
|
let observation = try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(res)
|
|
GatewayDiagnostics.log("agent.wait completed runId=\(runId) observation=\(observation)")
|
|
return observation
|
|
} catch {
|
|
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
|
|
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
|
|
return .unavailable
|
|
}
|
|
}
|
|
|
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
|
let res = try await gateway.request(OpenClawChatGatewayRequests.health(timeoutMs: timeoutMs))
|
|
return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true
|
|
}
|
|
|
|
func listQuestions() async throws -> [QuestionRecord] {
|
|
let data = try await gateway.request(OpenClawChatGatewayRequests.questionList())
|
|
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
|
|
}
|
|
|
|
func getQuestion(id: String) async throws -> QuestionRecord {
|
|
let data = try await gateway.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.gateway.request(OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers))
|
|
}
|
|
|
|
func cancelQuestion(id: String) async throws {
|
|
_ = try await self.gateway.request(OpenClawChatGatewayRequests.cancelQuestion(id: id))
|
|
}
|
|
|
|
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
|
AsyncStream { continuation in
|
|
let task = Task {
|
|
let stream = await self.gateway.subscribeServerEvents()
|
|
for await evt in stream {
|
|
if Task.isCancelled {
|
|
return
|
|
}
|
|
if let mapped = OpenClawChatGatewayPayloadCodec.event(from: evt) {
|
|
continuation.yield(mapped)
|
|
}
|
|
}
|
|
}
|
|
|
|
continuation.onTermination = { @Sendable _ in
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|