Move the visionOS Siti onto Ed Swift
EdAgent replaces OndeChatEngine, and the hand-written stream bridge becomes for-try-await over EdAgent.stream. Settings use setSystemPrompt and setSampling, and the saved conversation is restored with restoreHistory. The Ed package is taken by path from a sibling onde-ed-swift until Ed Swift 1.1.0 is released. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
paydii committed
Oct 8, 2026 at 22:58 UTC
8d9392105ea4e365753366c09f0f76492d578102
7 files changed
+72
-180
xcode/README.md
+10
-9
@@ -1,8 +1,8 @@
1
# Siti AI — visionOS app
2
3
-A native SwiftUI chat app for visionOS, built directly on the [`onde-swift`](https://github.com/ondeinference/onde-swift) SDK — the same on-device inference engine that powers the Tauri app at the repo root. This is a separate native client, not a Tauri build target; Tauri does not support visionOS.
3
+A native SwiftUI chat app for visionOS, built on [Ed Swift](https://github.com/ondeinference/ed-swift), the agent SDK over the same on-device Onde inference engine that powers the Tauri app at the repo root (through `ed-agent`). This is a separate native client, not a Tauri build target; Tauri does not support visionOS.
4
5
-Project layout and build-target conventions (window sizing, `xros`/`xrsimulator` platform settings, App Group entitlements, solid-image-stack app icon) follow the `SplitFireAIVision` target in the sibling `splitfire` repo (`../splitfire/frontend/xcode/splitfire/SplitFireAI.xcodeproj`). The chat engine wiring (streaming bridge, model lifecycle, conversation persistence) follows `onde-swift`'s own `Examples/OndeExample` app.
5
+Project layout and build-target conventions (window sizing, `xros`/`xrsimulator` platform settings, App Group entitlements, solid-image-stack app icon) follow the `SplitFireAIVision` target in the sibling `splitfire` repo (`../splitfire/frontend/xcode/splitfire/SplitFireAI.xcodeproj`). The chat engine wiring (streaming bridge, model lifecycle, conversation persistence) uses Ed's `stream`, `load(gguf:)` and `restoreHistory`.
6
7
## Setup
8
@@ -22,29 +22,30 @@ open SitiVision.xcodeproj
22
23
Set your development team under Signing & Capabilities (defaults to `2TQF86ZACD`, the same team as the Tauri app's iOS build — see `../src-tauri/tauri.conf.json`), pick a Vision Pro simulator or device, hit Run.
24
25
-### Developing against a local `onde-swift` checkout
25
+### Local Ed Swift checkout
26
27
-By default `project.yml` pulls `Onde` from the published `onde-swift` releases (`from: 1.1.0`), the same way `src-tauri/Cargo.toml` pins `onde = "1.1"` from crates.io. To iterate against a sibling `onde-swift` checkout instead — e.g. while adding a new SDK method — point the package at a local path the same way the Rust side's `[patch.crates-io]` block does:
27
+Until Ed Swift 1.1.0 is released, `project.yml` takes the `Ed` package by path from a sibling `onde-ed-swift` checkout (`../../onde-ed-swift`), the way `src-tauri/Cargo.toml` patches `ed-agent` to a sibling `onde-ed`. After the release, switch it to the published package:
28
29
```yaml
30
packages:
31
- Onde:
32
- path: ../../onde-swift
31
+ Ed:
32
+ url: https://github.com/ondeinference/ed-swift
33
+ from: 1.1.0
34
```
35
35
-Then, from that `onde-swift` checkout, build the visionOS slice before regenerating:
36
+To build the visionOS slice of the local checkout, run this in `onde-ed-swift`:
37
38
```
39
make visionos
40
```
41
41
-`make visionos` needs the sibling `onde` Rust crate checked out next to `onde-swift` (`ONDE_REPO` defaults to `../onde`) and a nightly Rust toolchain with `-Z build-std`, since visionOS is a tier-3 target.
42
+`make visionos` needs the sibling `onde-ed` Rust workspace (`ED_REPO` defaults to `../onde-ed`) and a nightly Rust toolchain with `-Z build-std`, since visionOS is a tier-3 target.
43
44
## What it does
45
46
On first launch, it downloads and loads the platform-default model (Qwen 2.5 3B on visionOS, same tier as macOS) and streams replies token-by-token. Conversations persist locally between launches. The model cache lives in the shared `group.com.ondeinference.apps` App Group, so a model already downloaded by the Tauri macOS/iOS build of Siti is reused here instead of being fetched twice.
47
47
-Current MVP feature set (mirrors `onde-swift`'s example app, reskinned for Siti):
48
+Current MVP feature set:
49
50
- token-by-token streaming with a stop button
51
- local conversation persistence between launches
xcode/SitiVision/ChatViewModel.swift
+30
-152
@@ -3,8 +3,8 @@
3
// Siti AI visionOS
4
//
5
6
+import Ed
7
import Foundation
7
-import Onde
8
9
// ─────────────────────────────────────────────────────────────────────────────
10
// MARK: - Message model
@@ -36,96 +36,18 @@ struct Message: Identifiable, Codable, Hashable {
36
}
37
38
private extension Message {
39
- var asChatMessage: ChatMessage {
39
+ var asChatMessage: EdChatMessage {
40
switch role {
41
case .user:
42
- return userMessage(content: text)
42
+ return EdChatMessage(role: .user, content: text)
43
case .assistant:
44
- return assistantMessage(content: text)
44
+ return EdChatMessage(role: .assistant, content: text)
45
}
46
}
47
}
48
49
-// ─────────────────────────────────────────────────────────────────────────────
50
-// MARK: - Stream bridge
51
-// ─────────────────────────────────────────────────────────────────────────────
52
-
53
-/// Thread-safe bridge between the Rust callback thread and Swift concurrency.
54
-private final class StreamBridge: @unchecked Sendable {
55
- private let lock = NSLock()
56
- private var continuation: AsyncStream<String>.Continuation?
57
- private var cancelled = false
58
-
59
- func attach(_ continuation: AsyncStream<String>.Continuation) {
60
- lock.lock()
61
- self.continuation = continuation
62
- let shouldFinish = cancelled
63
- lock.unlock()
64
-
65
- if shouldFinish {
66
- continuation.finish()
67
- }
68
- }
69
-
70
- func yield(_ delta: String) {
71
- lock.lock()
72
- guard !cancelled else {
73
- lock.unlock()
74
- return
75
- }
76
- let continuation = continuation
77
- lock.unlock()
78
- continuation?.yield(delta)
79
- }
80
-
81
- func finish() {
82
- lock.lock()
83
- let continuation = continuation
84
- self.continuation = nil
85
- lock.unlock()
86
- continuation?.finish()
87
- }
88
-
89
- func cancel() {
90
- lock.lock()
91
- cancelled = true
92
- let continuation = continuation
93
- self.continuation = nil
94
- lock.unlock()
95
- continuation?.finish()
96
- }
97
-
98
- var isCancelled: Bool {
99
- lock.lock()
100
- let cancelled = cancelled
101
- lock.unlock()
102
- return cancelled
103
- }
104
-}
105
-
106
-/// Bridges the UniFFI callback interface to an `AsyncStream<String>`.
107
-private final class ChunkCollector: StreamChunkListener {
108
- private let bridge: StreamBridge
109
-
110
- init(bridge: StreamBridge) {
111
- self.bridge = bridge
112
- }
113
-
114
- /// Called by the Rust runtime for each streamed token.
115
- /// - Returns: `true` to keep streaming, `false` to cancel.
116
- func onChunk(chunk: StreamChunk) -> Bool {
117
- if bridge.isCancelled {
118
- return false
119
- }
120
-
121
- if chunk.done {
122
- bridge.finish()
123
- return false
124
- }
125
-
126
- bridge.yield(chunk.delta)
127
- return !bridge.isCancelled
128
- }
49
+private extension EdEngineInfo {
50
+ static let unloaded = EdEngineInfo(status: .unloaded)
51
}
52
53
// ─────────────────────────────────────────────────────────────────────────────
@@ -142,12 +64,7 @@ final class ChatViewModel: ObservableObject {
64
@Published private(set) var isModelReady: Bool = false
65
@Published private(set) var isSending: Bool = false
66
@Published private(set) var loadingProgress: String = "Preparing model…"
145
- @Published private(set) var engineInfo: EngineInfo = EngineInfo(
146
- status: .unloaded,
147
- modelName: nil,
148
- approxMemory: nil,
149
- historyLength: 0
150
- )
67
+ @Published private(set) var engineInfo: EdEngineInfo = .unloaded
68
69
@Published var alertError: IdentifiableError? = nil
70
@@ -155,9 +72,8 @@ final class ChatViewModel: ObservableObject {
72
73
// Lazy so the Rust/UniFFI runtime isn't initialized during
74
// @StateObject construction on the UIKit event-fetch thread.
158
- private var engine: OndeChatEngine?
75
+ private var engine: EdAgent?
76
private var streamingTask: Task<Void, Never>?
160
- private var streamBridge: StreamBridge?
77
private var didHydrateEngineHistory = false
78
79
private static let persistedMessagesKey = "ai.siti.vision.messages"
@@ -166,9 +82,9 @@ final class ChatViewModel: ObservableObject {
82
self.messages = Self.loadPersistedMessages()
83
}
84
169
- private func getOrCreateEngine() -> OndeChatEngine {
85
+ private func getOrCreateEngine() -> EdAgent {
86
if let existing = engine { return existing }
171
- let new = OndeChatEngine()
87
+ let new = EdAgent(appID: nil)
88
engine = new
89
return new
90
}
@@ -221,12 +137,12 @@ final class ChatViewModel: ObservableObject {
137
let normalizedPrompt = normalizedSystemPrompt(systemPrompt)
138
139
if let normalizedPrompt {
224
- await engine.setSystemPrompt(prompt: normalizedPrompt)
140
+ await engine.setSystemPrompt(normalizedPrompt)
141
} else {
142
await engine.clearSystemPrompt()
143
}
144
229
- await engine.setSampling(sampling: samplingPreset.samplingConfig)
145
+ await engine.setSampling(samplingPreset.samplingConfig)
146
await refreshEngineInfo()
147
}
148
@@ -239,12 +155,7 @@ final class ChatViewModel: ObservableObject {
155
_ = await engine.clearHistory()
156
await refreshEngineInfo()
157
} else {
242
- engineInfo = EngineInfo(
243
- status: .unloaded,
244
- modelName: nil,
245
- approxMemory: nil,
246
- historyLength: 0
247
- )
158
+ engineInfo = .unloaded
159
}
160
}
161
@@ -270,10 +181,10 @@ final class ChatViewModel: ObservableObject {
181
}
182
183
func cancelStreaming() {
273
- streamBridge?.cancel()
184
+ // Cancelling the task ends the `for try await` in `streamResponse`,
185
+ // which stops generation on the Rust side.
186
streamingTask?.cancel()
187
streamingTask = nil
276
- streamBridge = nil
188
189
if let idx = messages.indices.last, messages[idx].isStreaming {
190
if messages[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -286,10 +197,10 @@ final class ChatViewModel: ObservableObject {
197
198
isSending = false
199
if engineInfo.status == .generating {
289
- engineInfo = EngineInfo(
200
+ engineInfo = EdEngineInfo(
201
status: .ready,
202
modelName: engineInfo.modelName,
292
- approxMemory: engineInfo.approxMemory,
203
+ approximateMemory: engineInfo.approximateMemory,
204
historyLength: engineInfo.historyLength
205
)
206
}
@@ -321,7 +232,8 @@ final class ChatViewModel: ObservableObject {
232
}
233
234
do {
324
- let elapsed = try await getOrCreateEngine().loadDefaultModel(
235
+ let elapsed = try await getOrCreateEngine().load(
236
+ gguf: SitiDefaults.defaultModel,
237
systemPrompt: normalizedSystemPrompt(systemPrompt),
238
sampling: samplingPreset.samplingConfig
239
)
@@ -341,52 +253,28 @@ final class ChatViewModel: ObservableObject {
253
254
private func streamResponse(for userText: String) async {
255
isSending = true
344
- engineInfo = EngineInfo(
256
+ engineInfo = EdEngineInfo(
257
status: .generating,
258
modelName: engineInfo.modelName,
347
- approxMemory: engineInfo.approxMemory,
259
+ approximateMemory: engineInfo.approximateMemory,
260
historyLength: engineInfo.historyLength
261
)
262
263
let assistantIndex = messages.count
264
messages.append(Message(role: .assistant, text: "", isStreaming: true))
265
354
- let bridge = StreamBridge()
355
- streamBridge = bridge
356
- let listener = ChunkCollector(bridge: bridge)
357
-
358
- let stream = AsyncStream<String> { continuation in
359
- bridge.attach(continuation)
360
- }
361
-
362
- let capturedEngine = getOrCreateEngine()
363
- let producer = Task.detached(priority: .userInitiated) {
364
- do {
365
- try await streamChatMessage(
366
- engine: capturedEngine,
367
- message: userText,
368
- listener: listener
369
- )
370
- bridge.finish()
371
- } catch {
372
- bridge.finish()
373
- await MainActor.run {
374
- if !bridge.isCancelled {
375
- self.alertError = IdentifiableError(error)
376
- }
266
+ do {
267
+ for try await delta in getOrCreateEngine().stream(userText) {
268
+ if assistantIndex < messages.count {
269
+ messages[assistantIndex].text += delta
270
}
271
}
379
- }
380
-
381
- for await delta in stream {
382
- guard !Task.isCancelled else { break }
383
- if assistantIndex < messages.count {
384
- messages[assistantIndex].text += delta
272
+ } catch {
273
+ if !Task.isCancelled {
274
+ alertError = IdentifiableError(error)
275
}
276
}
277
388
- _ = await producer.result
389
-
278
let assistantHasText = assistantIndex < messages.count &&
279
!messages[assistantIndex].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
280
@@ -399,7 +287,6 @@ final class ChatViewModel: ObservableObject {
287
}
288
289
persistMessages()
402
- streamBridge = nil
290
streamingTask = nil
291
isSending = false
292
await refreshEngineInfo()
@@ -413,23 +300,14 @@ final class ChatViewModel: ObservableObject {
300
}
301
302
loadingProgress = "Restoring conversation…"
416
- let engine = getOrCreateEngine()
417
-
418
- for message in messages {
419
- await engine.pushHistory(message: message.asChatMessage)
420
- }
303
+ await getOrCreateEngine().restoreHistory(messages.map(\.asChatMessage))
304
305
didHydrateEngineHistory = true
306
}
307
308
private func refreshEngineInfo() async {
309
guard let engine else {
427
- engineInfo = EngineInfo(
428
- status: .unloaded,
429
- modelName: nil,
430
- approxMemory: nil,
431
- historyLength: 0
432
- )
310
+ engineInfo = .unloaded
311
return
312
}
313
xcode/SitiVision/ContentView.swift
+3
-3
@@ -4,7 +4,7 @@
4
//
5
6
import SwiftUI
7
-import Onde
7
+import Ed
8
9
// ─────────────────────────────────────────────────────────────────────────────
10
// MARK: - Root view
@@ -155,7 +155,7 @@ struct ContentView: View {
155
// ─────────────────────────────────────────────────────────────────────────────
156
157
private struct HeaderView: View {
158
- let info: EngineInfo
158
+ let info: EdEngineInfo
159
let isLoading: Bool
160
let isSending: Bool
161
@@ -202,7 +202,7 @@ private struct HeaderView: View {
202
203
Spacer()
204
205
- Label(info.approxMemory ?? "—", systemImage: "memorychip")
205
+ Label(info.approximateMemory ?? "—", systemImage: "memorychip")
206
Label("\(info.historyLength) turns", systemImage: "text.bubble")
207
208
Text(statusText)
xcode/SitiVision/InferenceEnvironment.swift
+1
-1
@@ -12,7 +12,7 @@ import Foundation
12
/// other instead of being fetched twice. Falls back to the app's own
13
/// Application Support directory if the group is unavailable.
14
///
15
-/// Call this once at launch, before creating an `OndeChatEngine`.
15
+/// Call this once at launch, before creating an `EdAgent`.
16
func setupInferenceEnvironment() {
17
let fm = FileManager.default
18
xcode/SitiVision/SitiDefaults.swift
+15
-5
@@ -3,8 +3,8 @@
3
// Siti AI visionOS
4
//
5
6
+import Ed
7
import SwiftUI
7
-import Onde
8
9
/// Mirrors `CHAT_SYSTEM_PROMPT` in `src-tauri/src/chat/mod.rs` so the native
10
/// visionOS client and the Tauri desktop/mobile client present the same
@@ -20,6 +20,14 @@ enum SitiDefaults {
20
You run locally, so you may not know about very recent events. When you are not sure, say so instead of inventing an answer.
21
"""
22
23
+ /// Qwen 2.5 3B (GGUF Q4_K_M), the default Onde picks on visionOS.
24
+ static let defaultModel = EdGGUFModelConfiguration(
25
+ modelID: "bartowski/Qwen2.5-3B-Instruct-GGUF",
26
+ files: ["Qwen2.5-3B-Instruct-Q4_K_M.gguf"],
27
+ displayName: "Qwen 2.5 3B",
28
+ approximateMemory: "~1.93 GB (GGUF Q4_K_M)"
29
+ )
30
+
31
static let promptSuggestions: [PromptSuggestion] = [
32
PromptSuggestion(
33
title: "How can I help?",
@@ -76,14 +84,16 @@ enum SamplingPreset: String, CaseIterable, Identifiable {
84
}
85
}
86
79
- var samplingConfig: SamplingConfig {
87
+ /// The same three profiles Onde ships (`SamplingConfig::default`,
88
+ /// `deterministic` and `mobile`).
89
+ var samplingConfig: EdSamplingConfiguration {
90
switch self {
91
case .balanced:
82
- return defaultSamplingConfig()
92
+ return EdSamplingConfiguration(temperature: 0.7, topP: 0.95, maxTokens: 512)
93
case .deterministic:
84
- return deterministicSamplingConfig()
94
+ return EdSamplingConfiguration(temperature: 0.0, maxTokens: 512)
95
case .mobile:
86
- return mobileSamplingConfig()
96
+ return EdSamplingConfiguration(temperature: 0.7, topP: 0.95, maxTokens: 128)
97
}
98
}
99
}
xcode/SitiVision/SitiVisionApp.swift
+1
-1
@@ -9,7 +9,7 @@ import SwiftUI
9
struct SitiVisionApp: App {
10
11
init() {
12
- // Must be called before any OndeChatEngine interaction so the Rust
12
+ // Must be called before any EdAgent interaction so the Rust
13
// core can find its cache directories inside the app sandbox.
14
setupInferenceEnvironment()
15
}
xcode/project.yml
+12
-9
@@ -7,14 +7,17 @@ options:
7
generateEmptyDirectories: false
8
createIntermediateGroups: false
9
10
-# Onde is normally consumed from its published releases, same as the Rust
11
-# side pins `onde = "1.1"` from crates.io in src-tauri/Cargo.toml. To develop
12
-# against a local sibling `onde-swift` checkout instead, point this at a
13
-# `path:` the same way src-tauri's `[patch.crates-io]` block does.
10
+# Ed Swift bundles the Onde inference engine, the same one the Rust side
11
+# reaches through `ed-agent` in src-tauri/Cargo.toml. Until Ed Swift 1.1.0 is
12
+# released the package is taken by path from a sibling checkout, the same way
13
+# src-tauri's `[patch.crates-io]` block points at onde-ed. After the release,
14
+# switch to:
15
+# Ed:
16
+# url: https://github.com/ondeinference/ed-swift
17
+# from: 1.1.0
18
packages:
15
- Onde:
16
- url: https://github.com/ondeinference/onde-swift
17
- from: 1.2.2
19
+ Ed:
20
+ path: ../../onde-ed-swift
21
22
schemes:
23
SitiVision:
@@ -48,8 +51,8 @@ targets:
51
com.apple.security.application-groups:
52
- group.com.ondeinference.apps
53
dependencies:
51
- - package: Onde
52
- product: Onde
54
+ - package: Ed
55
+ product: Ed
56
settings:
57
base:
58
PRODUCT_BUNDLE_IDENTIFIER: ai.siti.Siti