Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
192a420
feat(android): port CameraAccessAndroid code updates for upstream PR
Lee-daeho Mar 16, 2026
827d114
fix(android): stabilize streaming service types and OpenClaw protocol
Lee-daeho Mar 16, 2026
c91fe66
Add chat transcript UI with Camera/Chat tab switcher (iOS + Android)
sseanliu Mar 24, 2026
54d0e12
Fix chat transcript text colors for light theme on Android
sseanliu Mar 24, 2026
fd0a5d9
Enable text selection in chat transcript on Android
sseanliu Mar 24, 2026
bdd8af2
Add session log analysis scripts for UIST paper data collection
sseanliu Mar 26, 2026
46b3a8b
Add RemoteLogger for persistent session logging (port from IntentOS)
sseanliu Mar 26, 2026
fa7027e
Store voice logs in ~/.openclaw/visionclaw-logs/ for agent extraction
sseanliu Mar 26, 2026
5a25ea4
Add capture_photo tool and gallery for Gemini-triggered photo capture…
sseanliu Mar 26, 2026
20a782b
Make gallery button always visible on both Camera and Chat tabs
sseanliu Mar 27, 2026
9365458
Add opt-in image passing from Gemini execute tool to OpenClaw
sseanliu Mar 27, 2026
f8d7cf3
Add RemoteLogger for persistent session logging on Android
sseanliu Mar 27, 2026
3f907d3
Add capture_photo tool and gallery for Gemini-triggered photo capture…
sseanliu Mar 27, 2026
6e3bd0d
Make image upload opt-in via include_image param on execute tool (And…
sseanliu Mar 27, 2026
b96874d
Fix system prompt to mention capture_photo tool (iOS + Android)
sseanliu Mar 27, 2026
52292f0
Add include_image guidance to system prompt (iOS + Android)
sseanliu Mar 27, 2026
b1f2079
Send image inline as base64 instead of uploading to media server (And…
sseanliu Mar 27, 2026
0ab0b83
Make include_image prompt more explicit for photo sending tasks
sseanliu Mar 27, 2026
a3f1b06
Route image tasks through WebSocket chat.send instead of HTTP (Android)
sseanliu Mar 27, 2026
279c736
Wait for actual agent reply on chat.send instead of treating ack as r…
sseanliu Mar 27, 2026
747ad65
Make execute tool NON_BLOCKING with INTERRUPT scheduling for async re…
sseanliu Mar 27, 2026
aee3b89
Sync iOS with Android: NON_BLOCKING execute, WebSocket image sending,…
sseanliu Mar 27, 2026
2a8d498
Auto-attach camera frame on every execute call when video is enabled …
sseanliu Mar 27, 2026
a51de45
Default include_image to false, Gemini must opt in (iOS + Android)
sseanliu Mar 27, 2026
7f2002f
Auto-save frame to gallery when image is attached to execute call (iO…
sseanliu Mar 27, 2026
739c960
Ensure messages are cleared on session stop
sseanliu Mar 27, 2026
a843f56
Clear pending WebSocket callbacks on disconnect to prevent ghost tool…
sseanliu Mar 27, 2026
4149218
Increase Gemini WebSocket ping interval to 30s to prevent timeout dur…
sseanliu Mar 27, 2026
9e9cec8
Show gallery as overlay instead of replacing StreamScreen to prevent …
sseanliu Mar 27, 2026
4888ef5
Add session history with timestamps and dividers (iOS + Android)
sseanliu Mar 27, 2026
04ab7ed
Persist chat history to disk across app restarts (iOS + Android)
sseanliu Mar 27, 2026
d1e5905
Revert to BLOCKING execute tool to fix duplicate response display (iO…
sseanliu Mar 27, 2026
b7fd5a2
Fix background execution: indefinite WakeLock + WiFi lock for Gemini …
sseanliu Mar 27, 2026
1c746c0
Start foreground service when Gemini session starts to survive screen…
sseanliu Mar 27, 2026
f10d08a
Show Camera/Chat tab switcher always, not just when Gemini is active …
sseanliu Mar 27, 2026
2c6556d
Add swipeable Camera/Chat pager - swipe anywhere on screen to switch …
sseanliu Mar 27, 2026
8bf122e
Add swipeable Camera/Chat tabs always visible on iOS (matching Android)
sseanliu Mar 27, 2026
a9eb326
Move debug menu from floating button to Settings screen (Android)
sseanliu Mar 27, 2026
0b9d1e7
Remove Live streaming button from controls row (Android)
sseanliu Mar 27, 2026
cfdb81e
Center tab switcher, move gallery to top right, remove capture + live…
sseanliu Mar 27, 2026
6bef18d
Replace audio-only text chip with compact video toggle icon (Android)
sseanliu Mar 27, 2026
45643c5
Always connect OpenClaw event client for image sending, not just when…
sseanliu Mar 27, 2026
b1b98be
Connect event client earlier at session start, not after Gemini conne…
sseanliu Mar 27, 2026
c6eaf38
Add operator.admin scope to WebSocket connect handshake to fix chat.s…
sseanliu Mar 27, 2026
2a69e76
Override Host header to localhost on WebSocket to fix scope issue thr…
sseanliu Mar 27, 2026
dce3f19
Save image to Mac filesystem alongside chat.send for agent file acces…
sseanliu Mar 27, 2026
be4a5fe
Fix upload port offset to +6 to avoid OpenClaw internal port conflict
sseanliu Mar 27, 2026
7010500
Finalize AI bubble after tool response so post-tool text goes into ne…
sseanliu Mar 27, 2026
76bbcea
Switch execute to NON_BLOCKING with INTERRUPT scheduling for async ag…
sseanliu Mar 27, 2026
2f0fbba
Keep mic and transcription active during NON_BLOCKING tool execution …
sseanliu Mar 27, 2026
5f78a5f
Upload photo to Mac on capture_photo so agent can access file for Dri…
sseanliu Mar 27, 2026
283466e
Fix capture_photo upload race condition by running on IO dispatcher
sseanliu Mar 27, 2026
727c2bb
1
sseanliu Mar 28, 2026
9e7667f
Update Android OpenClaw websocket protocol
ryosuzuki May 24, 2026
aa648b8
Add Android demo speaker mode
ryosuzuki May 24, 2026
bd8e27e
Use phone-style voice processing in demo audio mode
ryosuzuki May 24, 2026
30726e4
Simplify Android launcher icon
ryosuzuki May 24, 2026
0102acd
Refine Android launcher icon contrast
ryosuzuki May 24, 2026
5ebbeda
Match launcher icon color to Glyph
ryosuzuki May 24, 2026
9b3c25c
Use VisionClaw cover mark for launcher icon
ryosuzuki May 24, 2026
8b97cc8
Restore compact glasses launcher icon
ryosuzuki May 24, 2026
39e29ba
Restore glasses icon size
ryosuzuki May 24, 2026
dbe7e66
Set launcher glasses icon to medium size
ryosuzuki May 24, 2026
83f0e8d
Use app glasses mark for launcher icon
ryosuzuki May 24, 2026
34aef0c
Lower launcher glasses mark slightly
ryosuzuki May 24, 2026
5437c43
Add OpenClaw progress speech and developer controls
ryosuzuki May 25, 2026
de11523
Stabilize Android glasses streaming restart
ryosuzuki May 26, 2026
21fdf75
Merge origin main into beta
ryosuzuki May 26, 2026
8bdc5b5
Stabilize Android audio routing settings
ryosuzuki Jun 5, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ samples/CameraAccessAndroid/local.properties
samples/CameraAccessAndroid/.gradle/
samples/CameraAccessAndroid/build/
samples/CameraAccessAndroid/app/build/

# Server logs (now stored in ~/.openclaw/visionclaw-logs/ for agent access)
samples/CameraAccess/server/logs/
24 changes: 22 additions & 2 deletions samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
8FD96B7F2E6F0A9800F56AB1 /* CameraAccessApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FD96B792E6F0A9800F56AB1 /* CameraAccessApp.swift */; };
8FD96B812E6F0A9800F56AB1 /* HomeScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FD96B722E6F0A9800F56AB1 /* HomeScreenView.swift */; };
8FD96B872E6F0A9800F56AB1 /* StreamSessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FD96B6F2E6F0A9800F56AB1 /* StreamSessionViewModel.swift */; };
9DD6CC002F4A000000ED7098 /* VideoDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CBFF2F4A000000ED7098 /* VideoDecoder.swift */; };
8FD96B882E6F0A9800F56AB1 /* StreamSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FD96B752E6F0A9800F56AB1 /* StreamSessionView.swift */; };
8FD96B8A2E6F0A9800F56AB1 /* PhotoPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FD96B742E6F0A9800F56AB1 /* PhotoPreviewView.swift */; };
8FD96B8D2E6F0A9800F56AB1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8FD96B772E6F0A9800F56AB1 /* Assets.xcassets */; };
Expand All @@ -27,6 +26,8 @@
8FFD60542E849D0D0035E446 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD60532E849D0D0035E446 /* RegistrationView.swift */; };
8FFD60602E84A2F70035E446 /* MainAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD605F2E84A2F70035E446 /* MainAppView.swift */; };
8FFD60612E84A2F70035E446 /* DebugMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FFD605E2E84A2F70035E446 /* DebugMenuView.swift */; };
9D8CD52F2F746BF600E5149E /* ChatTranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8CD52D2F746BF600E5149E /* ChatTranscriptView.swift */; };
9D8CD5302F746BF600E5149E /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8CD52C2F746BF600E5149E /* ChatMessage.swift */; };
9DD6CAAF2F3C426600ED7098 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */; };
9DD6CAFE2F3C62DA00ED7098 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 9DD6CAFD2F3C62DA00ED7098 /* WebRTC */; };
9DD6CB052F3C637D00ED7098 /* WebRTCSessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CB032F3C637D00ED7098 /* WebRTCSessionViewModel.swift */; };
Expand All @@ -36,6 +37,7 @@
9DD6CB092F3C637D00ED7098 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CB012F3C637D00ED7098 /* WebRTCClient.swift */; };
9DD6CB0C2F3C648800ED7098 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 9DD6CB0B2F3C648800ED7098 /* WebRTC */; };
9DD6CB0E2F3C64F400ED7098 /* WebRTCOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CB0D2F3C64F400ED7098 /* WebRTCOverlayView.swift */; };
9DD6CC002F4A000000ED7098 /* VideoDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CBFF2F4A000000ED7098 /* VideoDecoder.swift */; };
9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */; };
9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894B02F4047630090B9B9 /* SettingsView.swift */; };
9DD895962F405E0E0090B9B9 /* RTCVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD895952F405E0E0090B9B9 /* RTCVideoView.swift */; };
Expand All @@ -45,6 +47,7 @@
A1B2C3D42F0A000200000003 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000003 /* AudioManager.swift */; };
A1B2C3D42F0A000200000004 /* GeminiSessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */; };
A1B2C3D42F0A000200000005 /* GeminiOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000005 /* GeminiOverlayView.swift */; };
A1B2C3D42F0A000200000006 /* RemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000006 /* RemoteLogger.swift */; };
E66D30242E7DA71900470B48 /* MockDeviceKitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66D30232E7DA71900470B48 /* MockDeviceKitButton.swift */; };
E6A188482EB918740097D0E1 /* StreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A188472EB918740097D0E1 /* StreamView.swift */; };
E6DA451D2E79A63100E3F688 /* MockDeviceCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DA45182E79A63100E3F688 /* MockDeviceCardView.swift */; };
Expand Down Expand Up @@ -81,7 +84,6 @@
8F2D237F2E856711002D0588 /* DebugMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuViewModel.swift; sourceTree = "<group>"; };
8F8F00772E8ACB4500A4BDAF /* WearablesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WearablesViewModel.swift; sourceTree = "<group>"; };
8FD96B6F2E6F0A9800F56AB1 /* StreamSessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSessionViewModel.swift; sourceTree = "<group>"; };
9DD6CBFF2F4A000000ED7098 /* VideoDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecoder.swift; sourceTree = "<group>"; };
8FD96B722E6F0A9800F56AB1 /* HomeScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenView.swift; sourceTree = "<group>"; };
8FD96B742E6F0A9800F56AB1 /* PhotoPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreviewView.swift; sourceTree = "<group>"; };
8FD96B752E6F0A9800F56AB1 /* StreamSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSessionView.swift; sourceTree = "<group>"; };
Expand All @@ -98,6 +100,8 @@
8FFD60532E849D0D0035E446 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
8FFD605E2E84A2F70035E446 /* DebugMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuView.swift; sourceTree = "<group>"; };
8FFD605F2E84A2F70035E446 /* MainAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppView.swift; sourceTree = "<group>"; };
9D8CD52C2F746BF600E5149E /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
9D8CD52D2F746BF600E5149E /* ChatTranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTranscriptView.swift; sourceTree = "<group>"; };
9DD6CAAD2F3C426600ED7098 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
9DD6CAAE2F3C426600ED7098 /* Secrets.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.swift.example; sourceTree = "<group>"; };
9DD6CAFF2F3C637D00ED7098 /* CustomVideoCapturer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomVideoCapturer.swift; sourceTree = "<group>"; };
Expand All @@ -106,6 +110,7 @@
9DD6CB022F3C637D00ED7098 /* WebRTCConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCConfig.swift; sourceTree = "<group>"; };
9DD6CB032F3C637D00ED7098 /* WebRTCSessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCSessionViewModel.swift; sourceTree = "<group>"; };
9DD6CB0D2F3C64F400ED7098 /* WebRTCOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCOverlayView.swift; sourceTree = "<group>"; };
9DD6CBFF2F4A000000ED7098 /* VideoDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecoder.swift; sourceTree = "<group>"; };
9DD894AF2F4047630090B9B9 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
9DD894B02F4047630090B9B9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
9DD895942F405E0E0090B9B9 /* PiPVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPVideoView.swift; sourceTree = "<group>"; };
Expand All @@ -115,6 +120,7 @@
A1B2C3D42F0A000100000003 /* AudioManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = "<group>"; };
A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiSessionViewModel.swift; sourceTree = "<group>"; };
A1B2C3D42F0A000100000005 /* GeminiOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiOverlayView.swift; sourceTree = "<group>"; };
A1B2C3D42F0A000100000006 /* RemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLogger.swift; sourceTree = "<group>"; };
E66D30232E7DA71900470B48 /* MockDeviceKitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceKitButton.swift; sourceTree = "<group>"; };
E699CC952E8150670052C240 /* CameraAccessTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CameraAccessTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
E6A188472EB918740097D0E1 /* StreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -203,6 +209,7 @@
8FD96B7D2E6F0A9800F56AB1 /* CameraAccess */ = {
isa = PBXGroup;
children = (
9D8CD52E2F746BF600E5149E /* Chat */,
9DD894B12F4047630090B9B9 /* Settings */,
9DD6CB042F3C637D00ED7098 /* WebRTC */,
9DD6CAAD2F3C426600ED7098 /* Secrets.swift */,
Expand Down Expand Up @@ -242,6 +249,15 @@
path = MockDeviceKit;
sourceTree = "<group>";
};
9D8CD52E2F746BF600E5149E /* Chat */ = {
isa = PBXGroup;
children = (
9D8CD52C2F746BF600E5149E /* ChatMessage.swift */,
9D8CD52D2F746BF600E5149E /* ChatTranscriptView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
9DD6CB042F3C637D00ED7098 /* WebRTC */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -273,6 +289,7 @@
A1B2C3D42F0A000100000001 /* GeminiConfig.swift */,
A1B2C3D42F0A000100000002 /* GeminiLiveService.swift */,
A1B2C3D42F0A000100000004 /* GeminiSessionViewModel.swift */,
A1B2C3D42F0A000100000006 /* RemoteLogger.swift */,
);
path = Gemini;
sourceTree = "<group>";
Expand Down Expand Up @@ -434,9 +451,12 @@
A1B2C3D42F0A000200000002 /* GeminiLiveService.swift in Sources */,
A1B2C3D42F0A000200000003 /* AudioManager.swift in Sources */,
A1B2C3D42F0A000200000004 /* GeminiSessionViewModel.swift in Sources */,
9D8CD52F2F746BF600E5149E /* ChatTranscriptView.swift in Sources */,
9D8CD5302F746BF600E5149E /* ChatMessage.swift in Sources */,
9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */,
9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */,
A1B2C3D42F0A000200000005 /* GeminiOverlayView.swift in Sources */,
A1B2C3D42F0A000200000006 /* RemoteLogger.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
88 changes: 88 additions & 0 deletions samples/CameraAccess/CameraAccess/Chat/ChatHistoryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation

enum ChatHistoryStore {
private static let filename = "chat_history.json"
private static let maxMessages = 500

private static var fileURL: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent(filename)
}

static func save(_ messages: [ChatMessage]) {
let toSave = Array(messages.suffix(maxMessages))
let records: [[String: Any]] = toSave.map { msg in
[
"id": msg.id,
"role": serializeRole(msg.role),
"text": msg.text,
"timestamp": msg.timestamp.timeIntervalSince1970,
"status": serializeStatus(msg.status)
]
}
guard let data = try? JSONSerialization.data(withJSONObject: records) else { return }
try? data.write(to: fileURL)
}

static func load() -> [ChatMessage] {
guard FileManager.default.fileExists(atPath: fileURL.path),
let data = try? Data(contentsOf: fileURL),
let records = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return records.compactMap { obj in
guard let id = obj["id"] as? String,
let roleStr = obj["role"] as? String,
let timestamp = obj["timestamp"] as? TimeInterval else { return nil }
let text = obj["text"] as? String ?? ""
let statusStr = obj["status"] as? String ?? "complete"
return ChatMessage(
id: id,
role: deserializeRole(roleStr),
text: text,
timestamp: Date(timeIntervalSince1970: timestamp),
status: deserializeStatus(statusStr)
)
}
}

// MARK: - Serialization

private static func serializeRole(_ role: ChatMessageRole) -> String {
switch role {
case .user: return "user"
case .assistant: return "assistant"
case .toolCall(let name): return "tool:\(name)"
case .sessionDivider: return "divider"
}
}

private static func deserializeRole(_ s: String) -> ChatMessageRole {
switch s {
case "user": return .user
case "assistant": return .assistant
case "divider": return .sessionDivider
default:
if s.hasPrefix("tool:") { return .toolCall(String(s.dropFirst(5))) }
return .assistant
}
}

private static func serializeStatus(_ status: ChatMessageStatus) -> String {
switch status {
case .streaming: return "streaming"
case .complete: return "complete"
case .error(let msg): return "error:\(msg)"
}
}

private static func deserializeStatus(_ s: String) -> ChatMessageStatus {
switch s {
case "complete": return .complete
case "streaming": return .complete // treat stale streaming as complete
default:
if s.hasPrefix("error:") { return .error(String(s.dropFirst(6))) }
return .complete
}
}
}
38 changes: 38 additions & 0 deletions samples/CameraAccess/CameraAccess/Chat/ChatMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

struct ChatMessage: Identifiable, Equatable {
let id: String
let role: ChatMessageRole
var text: String
let timestamp: Date
var status: ChatMessageStatus

init(role: ChatMessageRole, text: String, status: ChatMessageStatus = .complete) {
self.id = UUID().uuidString
self.role = role
self.text = text
self.timestamp = Date()
self.status = status
}

init(id: String, role: ChatMessageRole, text: String, timestamp: Date, status: ChatMessageStatus = .complete) {
self.id = id
self.role = role
self.text = text
self.timestamp = timestamp
self.status = status
}
}

enum ChatMessageRole: Equatable {
case user
case assistant
case toolCall(String) // tool name
case sessionDivider // separator between sessions
}

enum ChatMessageStatus: Equatable {
case streaming
case complete
case error(String)
}
Loading