Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ const val openClawPort = 18789
const val openClawGatewayToken = "your-gateway-token-here"
```

Optional runtime settings (in-app **Settings**):
- **Model Override** -- if set, VisionClaw applies `/model <value>` for the current OpenClaw session and reapplies if changed.
- **Thinking Level** -- if set, VisionClaw applies `/think <value>` for the current session and reapplies if changed.
- Leaving either field empty explicitly clears that remote override and returns to gateway defaults (`/model default`, `/think default`, with fallback commands).

To find your Mac's Bonjour hostname: **System Settings > General > Sharing** -- it's shown at the top (e.g., `Johns-MacBook-Pro.local`).

> Both iOS and Android also have an in-app Settings screen where you can change these values at runtime without editing source code.
Expand Down
2 changes: 2 additions & 0 deletions samples/CameraAccess/CameraAccess/Gemini/GeminiConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ enum GeminiConfig {
static var openClawPort: Int { SettingsManager.shared.openClawPort }
static var openClawHookToken: String { SettingsManager.shared.openClawHookToken }
static var openClawGatewayToken: String { SettingsManager.shared.openClawGatewayToken }
static var openClawModel: String { SettingsManager.shared.openClawModel }
static var openClawThinking: String { SettingsManager.shared.openClawThinking }

static func websocketURL() -> URL? {
guard apiKey != "YOUR_GEMINI_API_KEY" && !apiKey.isEmpty else { return nil }
Expand Down
242 changes: 219 additions & 23 deletions samples/CameraAccess/CameraAccess/OpenClaw/OpenClawBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,93 @@ enum OpenClawConnectionState: Equatable {
case unreachable(String)
}

protocol OpenClawBridgeConfig {
var host: String { get }
var port: Int { get }
var gatewayToken: String { get }
var modelOverride: String { get }
var thinkingOverride: String { get }
}

struct DefaultOpenClawBridgeConfig: OpenClawBridgeConfig {
var host: String { GeminiConfig.openClawHost }
var port: Int { GeminiConfig.openClawPort }
var gatewayToken: String { GeminiConfig.openClawGatewayToken }
var modelOverride: String { GeminiConfig.openClawModel }
var thinkingOverride: String { GeminiConfig.openClawThinking }
}

@MainActor
class OpenClawBridge: ObservableObject {
private enum AppliedSessionOverrideState: Equatable {
case unknown
case value(String)
case cleared
}

@Published var lastToolCallStatus: ToolCallStatus = .idle
@Published var connectionState: OpenClawConnectionState = .notConfigured

private let session: URLSession
private let pingSession: URLSession
private let config: OpenClawBridgeConfig
private let sessionKeyFactory: () -> String
private var sessionKey: String
private var conversationHistory: [[String: String]] = []
private var appliedSessionModel: AppliedSessionOverrideState = .unknown
private var appliedSessionThinking: AppliedSessionOverrideState = .unknown
private let maxHistoryTurns = 10

init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
self.session = URLSession(configuration: config)
init(
session: URLSession? = nil,
pingSession: URLSession? = nil,
config: OpenClawBridgeConfig = DefaultOpenClawBridgeConfig(),
sessionKeyFactory: @escaping () -> String = OpenClawBridge.newSessionKey
) {
if let session {
self.session = session
} else {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
self.session = URLSession(configuration: config)
}

let pingConfig = URLSessionConfiguration.default
pingConfig.timeoutIntervalForRequest = 5
self.pingSession = URLSession(configuration: pingConfig)
if let pingSession {
self.pingSession = pingSession
} else {
let pingConfig = URLSessionConfiguration.default
pingConfig.timeoutIntervalForRequest = 5
self.pingSession = URLSession(configuration: pingConfig)
}

self.sessionKey = OpenClawBridge.newSessionKey()
self.config = config
self.sessionKeyFactory = sessionKeyFactory
self.sessionKey = sessionKeyFactory()
}

func checkConnection() async {
guard GeminiConfig.isOpenClawConfigured else {
guard isConfigured else {
connectionState = .notConfigured
return
}
connectionState = .checking
guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else {
guard let url = gatewayURL else {
connectionState = .unreachable("Invalid URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization")
request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization")
do {
let (_, response) = try await pingSession.data(for: request)
if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) {
connectionState = .connected
NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode)
if let http = response as? HTTPURLResponse {
if (200...299).contains(http.statusCode) {
connectionState = .connected
NSLog("[OpenClaw] Gateway reachable (HTTP %d)", http.statusCode)
} else {
connectionState = .unreachable("HTTP \(http.statusCode)")
NSLog("[OpenClaw] Gateway check failed (HTTP %d)", http.statusCode)
}
} else {
connectionState = .unreachable("Unexpected response")
}
Expand All @@ -58,12 +104,156 @@ class OpenClawBridge: ObservableObject {
}

func resetSession() {
sessionKey = OpenClawBridge.newSessionKey()
sessionKey = sessionKeyFactory()
conversationHistory = []
appliedSessionModel = .unknown
appliedSessionThinking = .unknown
NSLog("[OpenClaw] New session: %@", sessionKey)
}

private static func newSessionKey() -> String {
private func normalizedSessionOverride(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}

private func parseAssistantContent(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String else {
return nil
}
return content
}

private var isConfigured: Bool {
let token = config.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines)
let host = config.host.trimmingCharacters(in: .whitespacesAndNewlines)
return token != "YOUR_OPENCLAW_GATEWAY_TOKEN"
&& !token.isEmpty
&& host != "http://YOUR_MAC_HOSTNAME.local"
&& !host.isEmpty
}

private var gatewayURL: URL? {
URL(string: "\(config.host):\(config.port)/v1/chat/completions")
}

private func sendSessionCommand(
command: String,
label: String
) async -> ToolResult {
guard let url = gatewayURL else {
return .failure("Invalid gateway URL")
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(sessionKey, forHTTPHeaderField: "x-openclaw-session-key")

let body: [String: Any] = [
"model": "openclaw",
"messages": [["role": "user", "content": command]],
"stream": false
]

do {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard (200...299).contains(statusCode) else {
return .failure("OpenClaw \(label) override failed (HTTP \(statusCode))")
}

guard let content = parseAssistantContent(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!content.isEmpty else {
return .failure("OpenClaw \(label) override failed: empty response")
}

let lower = content.lowercased()
if lower.contains("not allowed") || lower.contains("failed") || lower.contains("invalid") {
return .failure("OpenClaw \(label) override failed: \(content)")
}
return .success(content)
} catch {
return .failure("OpenClaw \(label) override error: \(error.localizedDescription)")
}
}

private func sendFirstSuccessfulSessionCommand(
commands: [String],
label: String
) async -> ToolResult {
var lastError = "OpenClaw \(label) override failed"
for command in commands {
switch await sendSessionCommand(command: command, label: label) {
case .success(let response):
return .success(response)
case .failure(let error):
lastError = error
}
}
return .failure(lastError)
}

private func applySessionOverridesIfNeeded(toolName: String) async -> ToolResult? {
let desiredModel = normalizedSessionOverride(config.modelOverride)
let desiredThinking = normalizedSessionOverride(config.thinkingOverride)?.lowercased()

if let model = desiredModel {
if appliedSessionModel != .value(model) {
switch await sendSessionCommand(command: "/model \(model)", label: "model") {
case .success:
appliedSessionModel = .value(model)
case .failure(let error):
lastToolCallStatus = .failed(toolName, error)
return .failure(error)
}
}
} else if appliedSessionModel != .cleared {
switch await sendFirstSuccessfulSessionCommand(
commands: ["/model default", "/model reset", "/model clear"],
label: "model clear"
) {
case .success:
appliedSessionModel = .cleared
case .failure(let error):
lastToolCallStatus = .failed(toolName, error)
return .failure(error)
}
}

if let thinking = desiredThinking {
if appliedSessionThinking != .value(thinking) {
switch await sendSessionCommand(command: "/think \(thinking)", label: "thinking") {
case .success:
appliedSessionThinking = .value(thinking)
case .failure(let error):
lastToolCallStatus = .failed(toolName, error)
return .failure(error)
}
}
} else if appliedSessionThinking != .cleared {
switch await sendFirstSuccessfulSessionCommand(
commands: ["/think default", "/think off"],
label: "thinking clear"
) {
case .success:
appliedSessionThinking = .cleared
case .failure(let error):
lastToolCallStatus = .failed(toolName, error)
return .failure(error)
}
}

return nil
}

private nonisolated static func newSessionKey() -> String {
let ts = ISO8601DateFormatter().string(from: Date())
return "agent:main:glass:\(ts)"
}
Expand All @@ -76,11 +266,15 @@ class OpenClawBridge: ObservableObject {
) async -> ToolResult {
lastToolCallStatus = .executing(toolName)

guard let url = URL(string: "\(GeminiConfig.openClawHost):\(GeminiConfig.openClawPort)/v1/chat/completions") else {
guard let url = gatewayURL else {
lastToolCallStatus = .failed(toolName, "Invalid URL")
return .failure("Invalid gateway URL")
}

if let overrideFailure = await applySessionOverridesIfNeeded(toolName: toolName) {
return overrideFailure
}

// Append the new user message to conversation history
conversationHistory.append(["role": "user", "content": task])

Expand All @@ -91,7 +285,7 @@ class OpenClawBridge: ObservableObject {

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(GeminiConfig.openClawGatewayToken)", forHTTPHeaderField: "Authorization")
request.setValue("Bearer \(config.gatewayToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(sessionKey, forHTTPHeaderField: "x-openclaw-session-key")

Expand All @@ -113,14 +307,16 @@ class OpenClawBridge: ObservableObject {
let bodyStr = String(data: data, encoding: .utf8) ?? "no body"
NSLog("[OpenClaw] Chat failed: HTTP %d - %@", code, String(bodyStr.prefix(200)))
lastToolCallStatus = .failed(toolName, "HTTP \(code)")
if code == 401 {
return .failure("OpenClaw unauthorized (HTTP 401). Check OpenClaw Gateway Token in Settings.")
}
if code == 403 {
return .failure("OpenClaw forbidden (HTTP 403). Check gateway auth mode/token.")
}
return .failure("Agent returned HTTP \(code)")
}

if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String {
if let content = parseAssistantContent(from: data) {
// Append assistant response to history for continuity
conversationHistory.append(["role": "assistant", "content": content])
NSLog("[OpenClaw] Agent result: %@", String(content.prefix(200)))
Expand Down
2 changes: 2 additions & 0 deletions samples/CameraAccess/CameraAccess/Secrets.swift.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ enum Secrets {
static let openClawPort = 18789
static let openClawHookToken = "YOUR_OPENCLAW_HOOK_TOKEN"
static let openClawGatewayToken = "YOUR_OPENCLAW_GATEWAY_TOKEN"
static let openClawModel = ""
static let openClawThinking = ""

// OPTIONAL: WebRTC signaling server URL (for live POV streaming)
// Run: cd samples/CameraAccess/server && npm install && npm start
Expand Down
15 changes: 14 additions & 1 deletion samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ final class SettingsManager {
case openClawPort
case openClawHookToken
case openClawGatewayToken
case openClawModel
case openClawThinking
case geminiSystemPrompt
case webrtcSignalingURL
}
Expand Down Expand Up @@ -54,6 +56,16 @@ final class SettingsManager {
set { defaults.set(newValue, forKey: Key.openClawGatewayToken.rawValue) }
}

var openClawModel: String {
get { defaults.string(forKey: Key.openClawModel.rawValue) ?? Secrets.openClawModel }
set { defaults.set(newValue, forKey: Key.openClawModel.rawValue) }
}

var openClawThinking: String {
get { defaults.string(forKey: Key.openClawThinking.rawValue) ?? Secrets.openClawThinking }
set { defaults.set(newValue, forKey: Key.openClawThinking.rawValue) }
}

// MARK: - WebRTC

var webrtcSignalingURL: String {
Expand All @@ -65,7 +77,8 @@ final class SettingsManager {

func resetAll() {
for key in [Key.geminiAPIKey, .geminiSystemPrompt, .openClawHost, .openClawPort,
.openClawHookToken, .openClawGatewayToken, .webrtcSignalingURL] {
.openClawHookToken, .openClawGatewayToken, .openClawModel, .openClawThinking,
.webrtcSignalingURL] {
defaults.removeObject(forKey: key.rawValue)
}
}
Expand Down
Loading