main
swift 485 lines 16.3 KB
Raw
1 import Foundation
2 import Onde
3
4 // ─────────────────────────────────────────────────────────────────────────────
5 // MARK: - Message model
6 // ─────────────────────────────────────────────────────────────────────────────
7
8 struct Message: Identifiable, Codable, Hashable {
9 enum Role: String, Codable {
10 case user
11 case assistant
12 }
13
14 let id: UUID
15 let role: Role
16 var text: String
17 /// True while the assistant is still streaming tokens for this message.
18 var isStreaming: Bool
19
20 init(
21 id: UUID = UUID(),
22 role: Role,
23 text: String,
24 isStreaming: Bool = false
25 ) {
26 self.id = id
27 self.role = role
28 self.text = text
29 self.isStreaming = isStreaming
30 }
31 }
32
33 private extension Message {
34 var asChatMessage: ChatMessage {
35 switch role {
36 case .user:
37 return userMessage(content: text)
38 case .assistant:
39 return assistantMessage(content: text)
40 }
41 }
42 }
43
44 // ─────────────────────────────────────────────────────────────────────────────
45 // MARK: - Stream bridge
46 // ─────────────────────────────────────────────────────────────────────────────
47
48 /// Thread-safe bridge between the Rust callback thread and Swift concurrency.
49 private final class StreamBridge: @unchecked Sendable {
50 private let lock = NSLock()
51 private var continuation: AsyncStream<String>.Continuation?
52 private var cancelled = false
53
54 func attach(_ continuation: AsyncStream<String>.Continuation) {
55 lock.lock()
56 self.continuation = continuation
57 let shouldFinish = cancelled
58 lock.unlock()
59
60 if shouldFinish {
61 continuation.finish()
62 }
63 }
64
65 func yield(_ delta: String) {
66 lock.lock()
67 guard !cancelled else {
68 lock.unlock()
69 return
70 }
71 let continuation = continuation
72 lock.unlock()
73 continuation?.yield(delta)
74 }
75
76 func finish() {
77 lock.lock()
78 let continuation = continuation
79 self.continuation = nil
80 lock.unlock()
81 continuation?.finish()
82 }
83
84 func cancel() {
85 lock.lock()
86 cancelled = true
87 let continuation = continuation
88 self.continuation = nil
89 lock.unlock()
90 continuation?.finish()
91 }
92
93 var isCancelled: Bool {
94 lock.lock()
95 let cancelled = cancelled
96 lock.unlock()
97 return cancelled
98 }
99 }
100
101 /// Bridges the UniFFI callback interface to an `AsyncStream<String>`.
102 private final class ChunkCollector: StreamChunkListener {
103 private let bridge: StreamBridge
104
105 init(bridge: StreamBridge) {
106 self.bridge = bridge
107 }
108
109 /// Called by the Rust runtime for each streamed token.
110 /// - Returns: `true` to keep streaming, `false` to cancel.
111 func onChunk(chunk: StreamChunk) -> Bool {
112 if bridge.isCancelled {
113 return false
114 }
115
116 if chunk.done {
117 bridge.finish()
118 return false
119 }
120
121 bridge.yield(chunk.delta)
122 return !bridge.isCancelled
123 }
124 }
125
126 // ─────────────────────────────────────────────────────────────────────────────
127 // MARK: - ViewModel
128 // ─────────────────────────────────────────────────────────────────────────────
129
130 @MainActor
131 final class ChatViewModel: ObservableObject {
132
133 // ── Published state ───────────────────────────────────────────────────────
134
135 @Published private(set) var messages: [Message]
136 @Published private(set) var isModelLoading: Bool = false
137 @Published private(set) var isModelReady: Bool = false
138 @Published private(set) var isSending: Bool = false
139 @Published private(set) var loadingProgress: String = "Preparing model…"
140 @Published private(set) var engineInfo: EngineInfo = EngineInfo(
141 status: .unloaded,
142 modelName: nil,
143 approxMemory: nil,
144 historyLength: 0
145 )
146
147 @Published var alertError: IdentifiableError? = nil
148
149 // ── Private state ─────────────────────────────────────────────────────────
150
151 // Lazy so the Rust/UniFFI runtime isn't initialized during
152 // @StateObject construction on the UIKit event-fetch thread.
153 private var engine: OndeChatEngine?
154 private var streamingTask: Task<Void, Never>?
155 private var streamBridge: StreamBridge?
156 private var didHydrateEngineHistory = false
157
158 private static let persistedMessagesKey = "onde.example.messages"
159
160 init() {
161 self.messages = Self.loadPersistedMessages()
162 }
163
164 private func getOrCreateEngine() -> OndeChatEngine {
165 if let existing = engine { return existing }
166 let new = OndeChatEngine()
167 engine = new
168 return new
169 }
170
171 var hasMessages: Bool {
172 !messages.isEmpty
173 }
174
175 var isBusy: Bool {
176 isModelLoading || isSending
177 }
178
179 // ─────────────────────────────────────────────────────────────────────────
180 // MARK: Model lifecycle
181 // ─────────────────────────────────────────────────────────────────────────
182
183 func loadModelIfNeeded(systemPrompt: String, samplingPreset: SamplingPreset) async {
184 if isModelReady {
185 await applySettings(systemPrompt: systemPrompt, samplingPreset: samplingPreset)
186 return
187 }
188
189 await loadModel(
190 systemPrompt: systemPrompt,
191 samplingPreset: samplingPreset,
192 forceReload: false
193 )
194 }
195
196 func retryLoadingModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
197 await loadModel(
198 systemPrompt: systemPrompt,
199 samplingPreset: samplingPreset,
200 forceReload: false
201 )
202 }
203
204 func reloadModel(systemPrompt: String, samplingPreset: SamplingPreset) async {
205 await loadModel(
206 systemPrompt: systemPrompt,
207 samplingPreset: samplingPreset,
208 forceReload: true
209 )
210 }
211
212 func applySettings(systemPrompt: String, samplingPreset: SamplingPreset) async {
213 guard isModelReady else { return }
214
215 let engine = getOrCreateEngine()
216 let normalizedPrompt = normalizedSystemPrompt(systemPrompt)
217
218 if let normalizedPrompt {
219 await engine.setSystemPrompt(prompt: normalizedPrompt)
220 } else {
221 await engine.clearSystemPrompt()
222 }
223
224 await engine.setSampling(sampling: samplingPreset.samplingConfig)
225 await refreshEngineInfo()
226 }
227
228 func clearConversation() async {
229 cancelStreaming()
230 messages.removeAll()
231 persistMessages()
232
233 if let engine {
234 _ = await engine.clearHistory()
235 await refreshEngineInfo()
236 } else {
237 engineInfo = EngineInfo(
238 status: .unloaded,
239 modelName: nil,
240 approxMemory: nil,
241 historyLength: 0
242 )
243 }
244 }
245
246 // ─────────────────────────────────────────────────────────────────────────
247 // MARK: Sending messages
248 // ─────────────────────────────────────────────────────────────────────────
249
250 func send(text: String) {
251 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
252 guard !trimmed.isEmpty, !isSending, isModelReady else { return }
253
254 alertError = nil
255 messages.append(Message(role: .user, text: trimmed))
256 persistMessages()
257
258 streamingTask = Task {
259 await streamResponse(for: trimmed)
260 }
261 }
262
263 func sendSuggestion(_ suggestion: PromptSuggestion) {
264 send(text: suggestion.prompt)
265 }
266
267 func cancelStreaming() {
268 streamBridge?.cancel()
269 streamingTask?.cancel()
270 streamingTask = nil
271 streamBridge = nil
272
273 if let idx = messages.indices.last, messages[idx].isStreaming {
274 if messages[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
275 messages.remove(at: idx)
276 } else {
277 messages[idx].isStreaming = false
278 }
279 persistMessages()
280 }
281
282 isSending = false
283 if engineInfo.status == .generating {
284 engineInfo = EngineInfo(
285 status: .ready,
286 modelName: engineInfo.modelName,
287 approxMemory: engineInfo.approxMemory,
288 historyLength: engineInfo.historyLength
289 )
290 }
291 }
292
293 // ─────────────────────────────────────────────────────────────────────────
294 // MARK: Private helpers
295 // ─────────────────────────────────────────────────────────────────────────
296
297 private func loadModel(
298 systemPrompt: String,
299 samplingPreset: SamplingPreset,
300 forceReload: Bool
301 ) async {
302 guard !isModelLoading else { return }
303
304 if forceReload {
305 cancelStreaming()
306 didHydrateEngineHistory = false
307 }
308
309 isModelLoading = true
310 isModelReady = false
311 alertError = nil
312 loadingProgress = forceReload ? "Reloading model…" : "Loading model…"
313
314 defer {
315 isModelLoading = false
316 }
317
318 do {
319 let elapsed = try await getOrCreateEngine().loadDefaultModel(
320 systemPrompt: normalizedSystemPrompt(systemPrompt),
321 sampling: samplingPreset.samplingConfig
322 )
323
324 loadingProgress = String(format: "Model ready in %.1f s", elapsed)
325 isModelReady = true
326 didHydrateEngineHistory = false
327 await restorePersistedConversationIfNeeded()
328 await refreshEngineInfo()
329 } catch {
330 isModelReady = false
331 await refreshEngineInfo()
332 alertError = IdentifiableError(error)
333 loadingProgress = "Unable to load model"
334 }
335 }
336
337 private func streamResponse(for userText: String) async {
338 isSending = true
339 engineInfo = EngineInfo(
340 status: .generating,
341 modelName: engineInfo.modelName,
342 approxMemory: engineInfo.approxMemory,
343 historyLength: engineInfo.historyLength
344 )
345
346 let assistantIndex = messages.count
347 messages.append(Message(role: .assistant, text: "", isStreaming: true))
348
349 let bridge = StreamBridge()
350 streamBridge = bridge
351 let listener = ChunkCollector(bridge: bridge)
352
353 let stream = AsyncStream<String> { continuation in
354 bridge.attach(continuation)
355 }
356
357 let capturedEngine = getOrCreateEngine()
358 let producer = Task.detached(priority: .userInitiated) {
359 do {
360 try await streamChatMessage(
361 engine: capturedEngine,
362 message: userText,
363 listener: listener
364 )
365 bridge.finish()
366 } catch {
367 bridge.finish()
368 await MainActor.run {
369 if !bridge.isCancelled {
370 self.alertError = IdentifiableError(error)
371 }
372 }
373 }
374 }
375
376 for await delta in stream {
377 guard !Task.isCancelled else { break }
378 if assistantIndex < messages.count {
379 messages[assistantIndex].text += delta
380 }
381 }
382
383 _ = await producer.result
384
385 let assistantHasText = assistantIndex < messages.count &&
386 !messages[assistantIndex].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
387
388 if assistantIndex < messages.count {
389 if assistantHasText {
390 messages[assistantIndex].isStreaming = false
391 } else {
392 messages.remove(at: assistantIndex)
393 }
394 }
395
396 persistMessages()
397 streamBridge = nil
398 streamingTask = nil
399 isSending = false
400 await refreshEngineInfo()
401 }
402
403 private func restorePersistedConversationIfNeeded() async {
404 guard !didHydrateEngineHistory else { return }
405 guard !messages.isEmpty else {
406 didHydrateEngineHistory = true
407 return
408 }
409
410 loadingProgress = "Restoring conversation…"
411 let engine = getOrCreateEngine()
412
413 for message in messages {
414 await engine.pushHistory(message: message.asChatMessage)
415 }
416
417 didHydrateEngineHistory = true
418 }
419
420 private func refreshEngineInfo() async {
421 guard let engine else {
422 engineInfo = EngineInfo(
423 status: .unloaded,
424 modelName: nil,
425 approxMemory: nil,
426 historyLength: 0
427 )
428 return
429 }
430
431 let info = await engine.info()
432 engineInfo = info
433 isModelReady = info.status == .ready || info.status == .generating
434 }
435
436 private func normalizedSystemPrompt(_ prompt: String) -> String? {
437 let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
438 return trimmed.isEmpty ? nil : trimmed
439 }
440
441 private func persistMessages() {
442 do {
443 let data = try JSONEncoder().encode(messages)
444 UserDefaults.standard.set(data, forKey: Self.persistedMessagesKey)
445 } catch {
446 logPersistenceFailure(error)
447 }
448 }
449
450 private static func loadPersistedMessages() -> [Message] {
451 guard let data = UserDefaults.standard.data(forKey: persistedMessagesKey) else {
452 return []
453 }
454
455 do {
456 return try JSONDecoder().decode([Message].self, from: data).map {
457 Message(id: $0.id, role: $0.role, text: $0.text, isStreaming: false)
458 }
459 } catch {
460 return []
461 }
462 }
463
464 private func logPersistenceFailure(_ error: Error) {
465 NSLog("OndeExample: failed to persist messages: %@", String(describing: error))
466 }
467 }
468
469 // ─────────────────────────────────────────────────────────────────────────────
470 // MARK: - Error wrapper
471 // ─────────────────────────────────────────────────────────────────────────────
472
473 /// Makes any `Error` `Identifiable` so it can drive a SwiftUI `.alert`.
474 struct IdentifiableError: Identifiable {
475 let id = UUID()
476 let underlying: Error
477
478 init(_ error: Error) {
479 self.underlying = error
480 }
481
482 var message: String {
483 underlying.localizedDescription
484 }
485 }