@sigit / siti / commits / 8d93921

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