@capgo/capacitor-llm 8.0.18 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,12 @@
1
1
  import Foundation
2
2
  import Capacitor
3
- import ObjectiveC
4
- import Darwin
5
3
  #if canImport(FoundationModels)
6
4
  import FoundationModels
7
5
  #endif
8
- // For CocoaPods, MediaPipeTasksGenAI should always be available
9
- // For SPM, we need conditional import when SPM support is added
10
- #if canImport(MediaPipeTasksGenAI) && !targetEnvironment(macCatalyst)
6
+ #if canImport(LiteRTLM)
7
+ import LiteRTLM
8
+ #endif
9
+ #if canImport(MediaPipeTasksGenAI)
11
10
  import MediaPipeTasksGenAI
12
11
  #endif
13
12
 
@@ -18,10 +17,9 @@ import MediaPipeTasksGenAI
18
17
 
19
18
  @objc(LLMPlugin)
20
19
  public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
21
- private let pluginVersion: String = "8.0.18"
22
- #if targetEnvironment(macCatalyst)
23
- private let catalystNotSupportedMessage = "CapgoLLM is not available on Mac Catalyst. Use an iOS/iPadOS target instead."
24
- #endif
20
+ private let pluginVersion: String = "8.1.0"
21
+ private let liteRtTopP: Float = 0.95
22
+
25
23
  public let identifier = "LLMPlugin"
26
24
  public let jsName = "CapgoLLM"
27
25
  public var pluginMethods: [CAPPluginMethod] = [
@@ -35,24 +33,8 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
35
33
 
36
34
  private enum ModelType {
37
35
  case appleIntelligence
36
+ case liteRtLm
38
37
  case mediaPipe
39
- case execuTorch
40
- }
41
-
42
- private final class ExecuTorchChatSession {
43
- private var turns: [(role: String, content: String)] = []
44
-
45
- func buildPrompt(userMessage: String) -> String {
46
- var parts = turns.map { "\($0.role): \($0.content)" }
47
- parts.append("User: \(userMessage)")
48
- parts.append("Assistant:")
49
- return parts.joined(separator: "\n")
50
- }
51
-
52
- func addTurn(userMessage: String, assistantMessage: String) {
53
- turns.append((role: "User", content: userMessage))
54
- turns.append((role: "Assistant", content: assistantMessage))
55
- }
56
38
  }
57
39
 
58
40
  private var currentModelType: ModelType = .appleIntelligence
@@ -71,17 +53,21 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
71
53
  private var appleChats: [String: Any] = [:]
72
54
  #endif
73
55
 
74
- #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
75
- private var llmInference: LlmInference?
56
+ #if canImport(LiteRTLM)
57
+ private var liteRtEngine: LiteRTLM.Engine?
58
+ private var liteRtChats: [String: LiteRTLM.Conversation] = [:]
59
+ private var liteRtTopk: Int = 40
60
+ private var liteRtTemperature: Float = 0.8
61
+ private var liteRtRandomSeed: Int = 0
76
62
  #endif
77
63
 
78
- private var execuTorchRunner: DynamicExecuTorchRunner?
79
- private var execuTorchChats: [String: ExecuTorchChatSession] = [:]
80
- private let execuTorchGenerationLock = NSLock()
81
- private let execuTorchNotLinkedMessage = "ExecuTorch is not linked in this iOS app. " +
82
- "It is not installed by CocoaPods; add the ExecuTorch Swift Package products " +
83
- "to the app target on iOS 17+ or choose another engine."
84
- private var execuTorchIsGenerating = false
64
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
65
+ private var llmInference: LlmInference?
66
+ private var mediaPipeChats: [String: LlmInference.Session] = [:]
67
+ private var mediaPipeTopk: Int = 40
68
+ private var mediaPipeTemperature: Float = 0.8
69
+ private var mediaPipeRandomSeed: Int = 0
70
+ #endif
85
71
 
86
72
  @objc func setModel(_ call: CAPPluginCall) {
87
73
  guard let path = call.getString("path") else {
@@ -89,45 +75,23 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
89
75
  return
90
76
  }
91
77
 
92
- #if targetEnvironment(macCatalyst)
93
- call.reject(catalystNotSupportedMessage)
94
- return
95
- #endif
96
-
97
- print("[CapgoLLM] Setting model path: \(path)")
98
-
99
- // Extract model parameters
100
78
  let maxTokens = call.getInt("maxTokens") ?? 2048
101
79
  let topk = call.getInt("topk") ?? 40
102
80
  let temperature = call.getFloat("temperature") ?? 0.8
103
- let modelType = call.getString("modelType") // Optional model type parameter
104
- let engine = call.getString("engine")?.lowercased() ?? "auto"
105
- let tokenizerPath = call.getString("tokenizerPath")
106
- let specialTokens = call.getArray("specialTokens", String.self) ?? []
107
- let sequenceLength = call.getInt("sequenceLength") ?? maxTokens
108
-
109
- guard sequenceLength > 0 else {
110
- call.reject("sequenceLength must be > 0")
111
- return
112
- }
113
-
114
- guard !isExecuTorchGenerating() else {
115
- call.reject("Cannot switch model while ExecuTorch is generating. Wait for aiFinished.")
116
- return
117
- }
81
+ let randomSeed = call.getInt("randomSeed") ?? 0
82
+ let modelType = call.getString("modelType")
118
83
 
119
84
  modelPath = path
120
- execuTorchChats.removeAll()
121
- #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
85
+ isReady = false
86
+ clearChatSessions()
87
+ #if canImport(LiteRTLM)
88
+ liteRtEngine = nil
89
+ #endif
90
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
122
91
  llmInference = nil
123
92
  #endif
124
- execuTorchRunner = nil
125
- isReady = false
126
93
 
127
- // Check if user wants to use Apple Intelligence
128
- if engine == "apple" || path.lowercased() == "apple intelligence" {
129
- print("[CapgoLLM] Switching to Apple Intelligence")
130
- execuTorchRunner = nil
94
+ if path.lowercased() == "apple intelligence" {
131
95
  currentModelType = .appleIntelligence
132
96
  isReady = true
133
97
  notifyListeners("readinessChange", data: ["readiness": "ready"])
@@ -135,218 +99,80 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
135
99
  return
136
100
  }
137
101
 
138
- if shouldUseExecuTorch(engine: engine, path: path, tokenizerPath: tokenizerPath) {
139
- currentModelType = .execuTorch
140
- setExecuTorchModel(
141
- call,
142
- path: path,
143
- tokenizerPath: tokenizerPath,
144
- modelType: modelType,
145
- specialTokens: specialTokens,
146
- sequenceLength: sequenceLength,
147
- temperature: temperature
148
- )
149
- return
150
- }
151
-
152
- #if !targetEnvironment(macCatalyst) && canImport(MediaPipeTasksGenAI)
153
- currentModelType = .mediaPipe
154
- Task {
155
- do {
156
- let modelURL: URL
157
-
158
- // Check if it's a path within the app bundle
159
- if path.hasPrefix("/") {
160
- modelURL = URL(fileURLWithPath: path)
161
- print("[CapgoLLM] Using absolute path: \(modelURL.path)")
162
- } else {
163
- // Try to find it in the app bundle
164
- let fileName: String
165
- let fileExtension: String
166
-
167
- // If modelType is provided, use it as the extension
168
- if let providedType = modelType, !providedType.isEmpty {
169
- // Remove any extension from path and use provided type
170
- fileName = (path as NSString).deletingPathExtension
171
- fileExtension = providedType
172
- print("[CapgoLLM] Using provided model type: \(fileExtension)")
173
- } else {
174
- // Extract from path
175
- fileName = (path as NSString).deletingPathExtension
176
- fileExtension = (path as NSString).pathExtension
177
- }
178
-
179
- print("[CapgoLLM] Looking for model in bundle: \(fileName) with type: \(fileExtension.isEmpty ? "bin" : fileExtension)")
102
+ let resolvedModelType = resolveModelType(path: path, modelType: modelType)
103
+ switch resolveModelKind(path: path, modelType: modelType) {
104
+ case .appleIntelligence:
105
+ currentModelType = .appleIntelligence
106
+ isReady = true
107
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
108
+ call.resolve()
180
109
 
181
- // Use Bundle.main.path as shown in MediaPipe docs
182
- guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fileExtension.isEmpty ? "bin" : fileExtension) else {
183
- print("[CapgoLLM] Error: Model file not found in bundle: \(path)")
184
- print("[CapgoLLM] Tried resource: '\(fileName)' with type: '\(fileExtension.isEmpty ? "bin" : fileExtension)'")
185
- call.reject("Model file not found in bundle: \(path)")
186
- return
187
- }
188
- modelURL = URL(fileURLWithPath: bundlePath)
189
- print("[CapgoLLM] Found model in bundle at path: \(bundlePath)")
190
-
191
- // For MediaPipe models, verify companion .litertlm file exists
192
- if fileExtension == "task" {
193
- let companionPath = Bundle.main.path(forResource: fileName, ofType: "litertlm")
194
- if companionPath == nil {
195
- print("[CapgoLLM] Warning: Companion .litertlm file not found for \(fileName)")
196
- // Don't fail here as some models might not need it
197
- } else {
198
- print("[CapgoLLM] Found companion file at path: \(companionPath!)")
199
- }
200
- }
110
+ case .liteRtLm:
111
+ #if canImport(LiteRTLM)
112
+ Task {
113
+ do {
114
+ let modelURL = try resolveModelURL(path: path, modelType: modelType)
115
+ let engine = try await initializeLiteRtEngine(modelURL: modelURL, maxTokens: maxTokens)
116
+ liteRtEngine = engine
117
+ liteRtTopk = topk
118
+ liteRtTemperature = temperature
119
+ liteRtRandomSeed = randomSeed
120
+ currentModelType = .liteRtLm
121
+ isReady = true
122
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
123
+ call.resolve()
124
+ } catch {
125
+ let message = "Failed to load model: \(error.localizedDescription)"
126
+ notifyListeners("readinessChange", data: ["readiness": message])
127
+ call.reject(message)
201
128
  }
202
-
203
- // Initialize MediaPipe LLM
204
- print("[CapgoLLM] Initializing MediaPipe with model at: \(modelURL.path)")
205
- let options = LlmInference.Options(modelPath: modelURL.path)
206
- options.maxTokens = maxTokens
207
- options.maxTopk = topk
208
-
209
- print("[CapgoLLM] Creating LlmInference instance with maxTokens: \(maxTokens), topk: \(topk), temperature: \(temperature)")
210
- llmInference = try LlmInference(options: options)
211
- currentModelType = .mediaPipe
212
- isReady = true
213
-
214
- print("[CapgoLLM] Model loaded successfully")
215
- notifyListeners("readinessChange", data: ["readiness": "ready"])
216
- call.resolve()
217
- } catch {
218
- llmInference = nil
219
- isReady = false
220
- print("[CapgoLLM] Error loading model: \(error.localizedDescription)")
221
- notifyListeners("readinessChange", data: ["readiness": "Failed to load model: \(error.localizedDescription)"])
222
- call.reject("Failed to load model: \(error.localizedDescription)")
223
129
  }
224
- }
225
- #else
226
- call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
227
- #endif
228
- }
229
-
230
- private func shouldUseExecuTorch(engine: String, path: String, tokenizerPath: String?) -> Bool {
231
- if path.lowercased() == "apple intelligence" {
232
- return false
233
- }
234
- if engine == "executorch" {
235
- return true
236
- }
237
- if engine == "apple" || engine == "mediapipe" {
238
- return false
239
- }
240
- if tokenizerPath?.isEmpty == false {
241
- return true
242
- }
243
- return path.lowercased().hasSuffix(".pte")
244
- }
245
-
246
- private func setExecuTorchModel(
247
- _ call: CAPPluginCall,
248
- path: String,
249
- tokenizerPath: String?,
250
- modelType: String?,
251
- specialTokens: [String],
252
- sequenceLength: Int,
253
- temperature: Float
254
- ) {
255
- execuTorchRunner = nil
256
-
257
- guard sequenceLength > 0 else {
258
- call.reject("sequenceLength must be > 0")
259
- return
260
- }
261
-
262
- guard DynamicExecuTorchRunner.isAvailable else {
263
- isReady = false
264
- notifyListeners("readinessChange", data: ["readiness": execuTorchNotLinkedMessage])
265
- call.reject(execuTorchNotLinkedMessage)
266
- return
267
- }
268
-
269
- guard let tokenizerPath = tokenizerPath, !tokenizerPath.isEmpty else {
270
- call.reject("tokenizerPath is required for ExecuTorch models")
271
- return
272
- }
130
+ #else
131
+ let message = resolvedModelType == "litertlm"
132
+ ? "LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager."
133
+ : "This custom model format is unavailable on iOS in CocoaPods builds. Use Apple Intelligence or integrate the plugin through Swift Package Manager for LiteRT-LM support."
134
+ call.reject(message)
135
+ #endif
273
136
 
274
- Task {
275
- do {
276
- let modelURL = try resolveModelURL(path: path, modelType: modelType, defaultExtension: "pte")
277
- let tokenizerURL = try resolveModelURL(path: tokenizerPath, modelType: nil, defaultExtension: "model")
278
-
279
- print("[CapgoLLM] Initializing ExecuTorch with model at: \(modelURL.path)")
280
- print("[CapgoLLM] Initializing ExecuTorch with tokenizer at: \(tokenizerURL.path)")
281
-
282
- execuTorchRunner = try DynamicExecuTorchRunner(
283
- modelPath: modelURL.path,
284
- tokenizerPath: tokenizerURL.path,
285
- specialTokens: specialTokens,
286
- sequenceLength: sequenceLength,
287
- temperature: Double(temperature)
288
- )
289
- currentModelType = .execuTorch
290
- isReady = true
291
-
292
- notifyListeners("readinessChange", data: ["readiness": "ready"])
293
- call.resolve()
294
- } catch {
295
- execuTorchRunner = nil
296
- isReady = false
297
- print("[CapgoLLM] Error loading ExecuTorch model: \(error.localizedDescription)")
298
- notifyListeners("readinessChange", data: ["readiness": "Failed to load ExecuTorch model: \(error.localizedDescription)"])
299
- call.reject("Failed to load ExecuTorch model: \(error.localizedDescription)")
137
+ case .mediaPipe:
138
+ #if canImport(MediaPipeTasksGenAI)
139
+ Task {
140
+ do {
141
+ let modelURL = try resolveModelURL(path: path, modelType: modelType)
142
+ let options = LlmInference.Options(modelPath: modelURL.path)
143
+ options.maxTokens = maxTokens
144
+ options.maxTopk = topk
145
+
146
+ llmInference = try LlmInference(options: options)
147
+ mediaPipeTopk = topk
148
+ mediaPipeTemperature = temperature
149
+ mediaPipeRandomSeed = randomSeed
150
+ currentModelType = .mediaPipe
151
+ isReady = true
152
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
153
+ call.resolve()
154
+ } catch {
155
+ let message = "Failed to load model: \(error.localizedDescription)"
156
+ notifyListeners("readinessChange", data: ["readiness": message])
157
+ call.reject(message)
158
+ }
300
159
  }
160
+ #else
161
+ call.reject("MediaPipe is not available. For iOS custom .litertlm models, integrate the plugin through Swift Package Manager to use LiteRT-LM.")
162
+ #endif
301
163
  }
302
164
  }
303
165
 
304
- private func resolveModelURL(path: String, modelType: String?, defaultExtension: String) throws -> URL {
305
- if path.hasPrefix("/") {
306
- return URL(fileURLWithPath: path)
307
- }
308
-
309
- let fileName: String
310
- let fileExtension: String
311
-
312
- if let providedType = modelType, !providedType.isEmpty {
313
- fileName = (path as NSString).deletingPathExtension
314
- fileExtension = providedType
315
- } else {
316
- fileName = (path as NSString).deletingPathExtension
317
- let extractedExtension = (path as NSString).pathExtension
318
- fileExtension = extractedExtension.isEmpty ? defaultExtension : extractedExtension
319
- }
320
-
321
- guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fileExtension) else {
322
- throw NSError(
323
- domain: "CapgoLLM",
324
- code: 404,
325
- userInfo: [NSLocalizedDescriptionKey: "Model file not found in bundle: \(path)"]
326
- )
327
- }
328
-
329
- return URL(fileURLWithPath: bundlePath)
330
- }
331
-
332
166
  @objc func createChat(_ call: CAPPluginCall) {
333
- #if targetEnvironment(macCatalyst)
334
- call.reject(catalystNotSupportedMessage)
335
- return
336
- #endif
337
-
338
- print("[CapgoLLM] Creating chat with model type: \(currentModelType)")
339
167
  switch currentModelType {
340
168
  case .appleIntelligence:
341
169
  #if canImport(FoundationModels)
342
170
  if #available(iOS 26.0, *) {
343
171
  let instructions = call.getString("instructions")
344
172
  let session = LanguageModelSession(instructions: instructions)
345
- let id = UUID()
346
- appleChats[id.uuidString] = session
347
- call.resolve([
348
- "id": id.uuidString
349
- ])
173
+ let id = UUID().uuidString
174
+ appleChats[id] = session
175
+ call.resolve(["id": id])
350
176
  } else {
351
177
  call.reject("Apple Intelligence requires iOS 26.0 or later")
352
178
  }
@@ -354,48 +180,52 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
354
180
  call.reject("Apple Intelligence is not available on this device")
355
181
  #endif
356
182
 
183
+ case .liteRtLm:
184
+ #if canImport(LiteRTLM)
185
+ Task {
186
+ do {
187
+ guard let engine = liteRtEngine else {
188
+ call.reject("LiteRT-LM engine not loaded")
189
+ return
190
+ }
191
+
192
+ let conversationConfig = try makeLiteRtConversationConfig(instructions: call.getString("instructions"))
193
+ let conversation = try await engine.createConversation(with: conversationConfig)
194
+ let id = UUID().uuidString
195
+ liteRtChats[id] = conversation
196
+ call.resolve(["id": id])
197
+ } catch {
198
+ call.reject("Failed to create LiteRT-LM chat: \(error.localizedDescription)")
199
+ }
200
+ }
201
+ #else
202
+ call.reject("LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager.")
203
+ #endif
204
+
357
205
  case .mediaPipe:
358
- #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
359
- guard llmInference != nil else {
360
- print("[CapgoLLM] Error: Model not loaded when creating chat")
206
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
207
+ guard let inference = llmInference else {
361
208
  call.reject("Model not loaded")
362
209
  return
363
210
  }
364
211
 
365
- // MediaPipe doesn't use sessions, just return a dummy ID
366
- let id = UUID().uuidString
367
- print("[CapgoLLM] Created MediaPipe chat with ID: \(id)")
368
- call.resolve([
369
- "id": id
370
- ])
212
+ do {
213
+ let session = try LlmInference.Session(llmInference: inference, options: makeMediaPipeSessionOptions())
214
+ let id = UUID().uuidString
215
+ mediaPipeChats[id] = session
216
+ call.resolve(["id": id])
217
+ } catch {
218
+ call.reject("Failed to create chat: \(error.localizedDescription)")
219
+ }
371
220
  #else
372
- call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
221
+ call.reject("MediaPipe is not available. Please install via CocoaPods.")
373
222
  #endif
374
-
375
- case .execuTorch:
376
- guard execuTorchRunner != nil else {
377
- call.reject("ExecuTorch model not loaded")
378
- return
379
- }
380
- let id = UUID().uuidString
381
- execuTorchChats[id] = ExecuTorchChatSession()
382
- print("[CapgoLLM] Created ExecuTorch chat with ID: \(id)")
383
- call.resolve([
384
- "id": id
385
- ])
386
223
  }
387
224
  }
388
225
 
389
226
  @objc func getReadiness(_ call: CAPPluginCall) {
390
- #if targetEnvironment(macCatalyst)
391
- call.resolve(["readiness": "unsupported_on_maccatalyst"])
392
- notifyListeners("readinessChange", data: ["readiness": "unsupported_on_maccatalyst"])
393
- return
394
- #endif
395
-
396
227
  let readiness = getReadinessStatus()
397
228
  call.resolve(["readiness": readiness])
398
- // Also notify listeners
399
229
  notifyListeners("readinessChange", data: ["readiness": readiness])
400
230
  }
401
231
 
@@ -416,27 +246,18 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
416
246
  case .unavailable(let other):
417
247
  return "Error: \(other)"
418
248
  }
419
- } else {
420
- return "Apple Intelligence requires iOS 26.0 or later"
421
249
  }
250
+ return "Apple Intelligence requires iOS 26.0 or later"
422
251
  #else
423
252
  return "Apple Intelligence is not available on this device"
424
253
  #endif
425
254
 
426
- case .mediaPipe:
427
- return isReady ? "ready" : "not_ready"
428
-
429
- case .execuTorch:
255
+ case .liteRtLm, .mediaPipe:
430
256
  return isReady ? "ready" : "not_ready"
431
257
  }
432
258
  }
433
259
 
434
260
  @objc func sendMessage(_ call: CAPPluginCall) {
435
- #if targetEnvironment(macCatalyst)
436
- call.reject(catalystNotSupportedMessage)
437
- return
438
- #endif
439
-
440
261
  let chatId = call.getString("chatId", "")
441
262
  guard let message = call.getString("message") else {
442
263
  call.reject("message not found")
@@ -447,23 +268,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
447
268
  case .appleIntelligence:
448
269
  #if canImport(FoundationModels)
449
270
  if #available(iOS 26.0, *), let model = appleModel as? SystemLanguageModel {
450
- switch model.availability {
451
- case .available:
452
- break
453
- case .unavailable(.deviceNotEligible):
454
- call.reject("error deviceNotEligible error")
455
- return
456
- case .unavailable(.appleIntelligenceNotEnabled):
457
- call.reject("error appleIntelligenceNotEnabled error")
458
- return
459
- case .unavailable(.modelNotReady):
460
- call.reject("error modelNotReady error")
461
- return
462
- case .unavailable(let other):
463
- call.reject("error \(other) error")
464
- return
465
- }
466
-
271
+ guard validateAppleAvailability(model: model, call: call) else { return }
467
272
  guard let chat = appleChats[chatId] as? LanguageModelSession else {
468
273
  call.reject("chat not found")
469
274
  return
@@ -474,47 +279,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
474
279
  return
475
280
  }
476
281
 
477
- Task {
478
- do {
479
- let stream = chat.streamResponse(to: message)
480
- for try await chunk in stream {
481
- print("[CapgoLLM] Chunk type: \(type(of: chunk))")
482
-
483
- // Extract content from Snapshot object using Mirror reflection
484
- let textChunk: String
485
- let mirror = Mirror(reflecting: chunk)
486
-
487
- // Try to find content property
488
- if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
489
- let content = contentProperty.value as? String {
490
- textChunk = content
491
- print("[CapgoLLM] Extracted content: \(content)")
492
- } else {
493
- // Fallback: try rawContent
494
- if let rawContentProperty = mirror.children.first(where: { $0.label == "rawContent" }),
495
- let rawContent = rawContentProperty.value as? String {
496
- textChunk = rawContent
497
- print("[CapgoLLM] Extracted rawContent: \(rawContent)")
498
- } else {
499
- // Final fallback
500
- textChunk = "\(chunk)"
501
- print("[CapgoLLM] Fallback to string conversion: \(textChunk)")
502
- }
503
- }
504
- let data: [String: Any] = [
505
- "chatId": chatId as String,
506
- "text": textChunk as String,
507
- "isChunk": true
508
- ]
509
- self.notifyListeners("textFromAi", data: data)
510
- }
511
- let finishData: [String: Any] = ["chatId": chatId as String]
512
- self.notifyListeners("aiFinished", data: finishData)
513
- call.resolve()
514
- } catch {
515
- call.reject("Failed to get response: \(error.localizedDescription)")
516
- }
517
- }
282
+ streamAppleResponse(chatId: chatId, message: message, chat: chat, call: call)
518
283
  } else {
519
284
  call.reject("Apple Intelligence requires iOS 26.0 or later")
520
285
  }
@@ -522,114 +287,35 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
522
287
  call.reject("Apple Intelligence is not available on this device")
523
288
  #endif
524
289
 
525
- case .mediaPipe:
526
- #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
527
- guard let inference = llmInference else {
528
- call.reject("Model not loaded")
290
+ case .liteRtLm:
291
+ #if canImport(LiteRTLM)
292
+ guard let conversation = liteRtChats[chatId] else {
293
+ call.reject("chat not found")
529
294
  return
530
295
  }
531
296
 
532
- Task {
533
- do {
534
- print("[CapgoLLM] Attempting to generate streaming response for message: \(message)")
535
-
536
- // Use native streaming API
537
- let resultStream = inference.generateResponseAsync(inputText: message)
538
-
539
- var fullResponse = ""
540
-
541
- for try await partialResult in resultStream {
542
- print("[CapgoLLM] Partial result: \(partialResult)")
543
-
544
- // Send the chunk
545
- self.notifyListeners("textFromAi", data: [
546
- "chatId": chatId,
547
- "text": partialResult,
548
- "isChunk": true
549
- ])
550
-
551
- // Accumulate for history (if needed)
552
- fullResponse += partialResult
553
- }
554
-
555
- print("[CapgoLLM] Streaming complete. Full response: \(fullResponse)")
556
-
557
- self.notifyListeners("aiFinished", data: ["chatId": chatId])
558
- call.resolve()
559
-
560
- } catch {
561
- print("[CapgoLLM] Error details: \(error)")
562
- call.reject("Failed to generate response: \(error.localizedDescription)")
563
- }
564
- }
297
+ streamLiteRtResponse(chatId: chatId, message: message, conversation: conversation, call: call)
565
298
  #else
566
- call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
299
+ call.reject("LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager.")
567
300
  #endif
568
301
 
569
- case .execuTorch:
570
- guard isReady, let runner = execuTorchRunner else {
571
- call.reject("ExecuTorch model not loaded")
302
+ case .mediaPipe:
303
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
304
+ guard llmInference != nil else {
305
+ call.reject("Model not loaded")
572
306
  return
573
307
  }
574
- guard let chat = execuTorchChats[chatId] else {
308
+
309
+ guard let session = mediaPipeChats[chatId] else {
575
310
  call.reject("chat not found")
576
311
  return
577
312
  }
578
- guard beginExecuTorchGeneration() else {
579
- call.reject("ExecuTorch is already generating. Wait for aiFinished before sending another message.")
580
- return
581
- }
582
-
583
- Task {
584
- defer {
585
- self.finishExecuTorchGeneration()
586
- }
587
-
588
- do {
589
- let prompt = chat.buildPrompt(userMessage: message)
590
- var fullResponse = ""
591
- print("[CapgoLLM] Attempting to generate ExecuTorch response for message: \(message)")
592
- try runner.generate(prompt) { token in
593
- self.notifyListeners("textFromAi", data: [
594
- "chatId": chatId,
595
- "text": token,
596
- "isChunk": true
597
- ])
598
- fullResponse += token
599
- }
600
- chat.addTurn(userMessage: message, assistantMessage: fullResponse)
601
- self.notifyListeners("aiFinished", data: ["chatId": chatId])
602
- call.resolve()
603
- } catch {
604
- print("[CapgoLLM] ExecuTorch error details: \(error)")
605
- call.reject("Failed to generate ExecuTorch response: \(error.localizedDescription)")
606
- }
607
- }
608
- }
609
- }
610
-
611
- private func isExecuTorchGenerating() -> Bool {
612
- execuTorchGenerationLock.lock()
613
- defer { execuTorchGenerationLock.unlock() }
614
- return execuTorchIsGenerating
615
- }
616
313
 
617
- private func beginExecuTorchGeneration() -> Bool {
618
- execuTorchGenerationLock.lock()
619
- defer { execuTorchGenerationLock.unlock() }
620
-
621
- if execuTorchIsGenerating {
622
- return false
314
+ streamMediaPipeResponse(chatId: chatId, message: message, session: session, call: call)
315
+ #else
316
+ call.reject("MediaPipe is not available. Please install via CocoaPods.")
317
+ #endif
623
318
  }
624
-
625
- execuTorchIsGenerating = true
626
- return true
627
- }
628
-
629
- private func finishExecuTorchGeneration() {
630
- execuTorchGenerationLock.lock()
631
- execuTorchIsGenerating = false
632
- execuTorchGenerationLock.unlock()
633
319
  }
634
320
 
635
321
  @objc func downloadModel(_ call: CAPPluginCall) {
@@ -645,19 +331,14 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
645
331
 
646
332
  let filename = call.getString("filename") ?? url.lastPathComponent
647
333
 
648
- // Get documents directory
649
334
  guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
650
335
  call.reject("Could not access documents directory")
651
336
  return
652
337
  }
653
338
 
654
339
  let destinationURL = documentsPath.appendingPathComponent(filename)
655
-
656
- // Create download task
657
340
  let session = URLSession(configuration: .default, delegate: DownloadDelegate(plugin: self), delegateQueue: nil)
658
- let downloadTask = session.downloadTask(with: url) { [weak self] (tempURL, _, error) in
659
- guard let self = self else { return }
660
-
341
+ let downloadTask = session.downloadTask(with: url) { tempURL, _, error in
661
342
  if let error = error {
662
343
  call.reject("Download failed: \(error.localizedDescription)")
663
344
  return
@@ -669,38 +350,31 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
669
350
  }
670
351
 
671
352
  do {
672
- // Remove existing file if it exists
673
353
  if FileManager.default.fileExists(atPath: destinationURL.path) {
674
354
  try FileManager.default.removeItem(at: destinationURL)
675
355
  }
676
356
 
677
- // Move downloaded file to documents directory
678
357
  try FileManager.default.moveItem(at: tempURL, to: destinationURL)
679
358
 
680
359
  var result = [
681
360
  "path": destinationURL.path
682
361
  ]
683
362
 
684
- // Handle companion URL if provided (for compatibility)
685
363
  if let companionUrlString = call.getString("companionUrl"),
686
364
  let companionUrl = URL(string: companionUrlString) {
687
-
688
365
  let companionFilename = companionUrl.lastPathComponent
689
366
  let companionDestination = documentsPath.appendingPathComponent(companionFilename)
690
367
 
691
- // Download companion file synchronously for simplicity
692
368
  do {
693
369
  let companionData = try Data(contentsOf: companionUrl)
694
370
  try companionData.write(to: companionDestination)
695
371
  result["companionPath"] = companionDestination.path
696
372
  } catch {
697
- // Log error but don't fail the whole download
698
373
  print("Failed to download companion file: \(error)")
699
374
  }
700
375
  }
701
376
 
702
377
  call.resolve(result)
703
-
704
378
  } catch {
705
379
  call.reject("Failed to save file: \(error.localizedDescription)")
706
380
  }
@@ -712,168 +386,277 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
712
386
  @objc func getPluginVersion(_ call: CAPPluginCall) {
713
387
  call.resolve(["version": self.pluginVersion])
714
388
  }
715
- }
716
389
 
717
- private typealias ExecuTorchAllocFunction = @convention(c) (AnyClass, Selector) -> Unmanaged<AnyObject>
718
- private typealias ExecuTorchInitRunnerFunction = @convention(c) (
719
- AnyObject,
720
- Selector,
721
- NSString,
722
- NSString,
723
- NSArray
724
- ) -> Unmanaged<AnyObject>
725
- private typealias ExecuTorchLoadFunction = @convention(c) (
726
- AnyObject,
727
- Selector,
728
- UnsafeMutablePointer<NSError?>
729
- ) -> Bool
730
- private typealias ExecuTorchConfigBlock = @convention(block) (NSObject) -> Void
731
- private typealias ExecuTorchInitConfigFunction = @convention(c) (
732
- AnyObject,
733
- Selector,
734
- ExecuTorchConfigBlock
735
- ) -> Unmanaged<AnyObject>
736
- private typealias ExecuTorchTokenCallback = @convention(block) (NSString) -> Void
737
- private typealias ExecuTorchGenerateFunction = @convention(c) (
738
- AnyObject,
739
- Selector,
740
- NSString,
741
- AnyObject,
742
- ExecuTorchTokenCallback,
743
- UnsafeMutablePointer<NSError?>
744
- ) -> Bool
745
- private typealias ExecuTorchVoidFunction = @convention(c) (AnyObject, Selector) -> Void
746
-
747
- private final class DynamicExecuTorchRunner {
748
- private static let runnerInitSelector = NSSelectorFromString("initWithModelPath:tokenizerPath:specialTokens:")
749
- private static let runnerLoadSelector = NSSelectorFromString("loadWithError:")
750
- private static let runnerResetSelector = NSSelectorFromString("reset")
751
- private static let runnerGenerateSelector = NSSelectorFromString("generateWithPrompt:config:tokenCallback:error:")
752
- private static let configInitSelector = NSSelectorFromString("initWithBlock:")
753
-
754
- static var isAvailable: Bool {
755
- guard let runnerClass = findClass("ExecuTorchLLMTextRunner"),
756
- let configClass = findClass("ExecuTorchLLMConfig") else {
757
- return false
390
+ private func clearChatSessions() {
391
+ #if canImport(FoundationModels)
392
+ appleChats.removeAll()
393
+ #endif
394
+ #if canImport(LiteRTLM)
395
+ liteRtChats.removeAll()
396
+ #endif
397
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
398
+ mediaPipeChats.removeAll()
399
+ #endif
400
+ }
401
+
402
+ private func resolveModelKind(path: String, modelType: String?) -> ModelType {
403
+ let resolvedModelType = resolveModelType(path: path, modelType: modelType)
404
+ switch resolvedModelType {
405
+ case "litertlm":
406
+ return .liteRtLm
407
+ case "task":
408
+ return .mediaPipe
409
+ default:
410
+ #if canImport(LiteRTLM)
411
+ return .liteRtLm
412
+ #else
413
+ return .mediaPipe
414
+ #endif
758
415
  }
416
+ }
759
417
 
760
- return hasInstanceMethod(runnerClass, runnerInitSelector) &&
761
- hasInstanceMethod(runnerClass, runnerLoadSelector) &&
762
- hasInstanceMethod(runnerClass, runnerResetSelector) &&
763
- hasInstanceMethod(runnerClass, runnerGenerateSelector) &&
764
- hasInstanceMethod(configClass, configInitSelector)
418
+ private func resolveModelType(path: String, modelType: String?) -> String {
419
+ if let modelType, !modelType.isEmpty {
420
+ return stripURLSuffix(modelType).lowercased()
421
+ }
422
+
423
+ let normalizedPath = stripURLSuffix(path)
424
+ let pathExtension = (normalizedPath as NSString).pathExtension
425
+ return pathExtension.lowercased()
426
+ }
427
+
428
+ private func stripURLSuffix(_ value: String) -> String {
429
+ let queryIndex = value.firstIndex(of: "?")
430
+ let fragmentIndex = value.firstIndex(of: "#")
431
+ let cutoff = [queryIndex, fragmentIndex].compactMap { $0 }.min() ?? value.endIndex
432
+ return String(value[..<cutoff])
765
433
  }
766
434
 
767
- private let runner: AnyObject
768
- private let sequenceLength: Int
769
- private let temperature: Double
770
-
771
- init(
772
- modelPath: String,
773
- tokenizerPath: String,
774
- specialTokens: [String],
775
- sequenceLength: Int,
776
- temperature: Double
777
- ) throws {
778
- guard let runnerClass = Self.findClass("ExecuTorchLLMTextRunner") else {
779
- throw Self.error("ExecuTorchLLMTextRunner was not found. Link ExecuTorch in the app target.")
435
+ private func resolveModelURL(path: String, modelType: String?) throws -> URL {
436
+ if path.hasPrefix("/") {
437
+ return URL(fileURLWithPath: path)
438
+ }
439
+
440
+ let fileName: String
441
+ let fileExtension: String
442
+
443
+ if let modelType, !modelType.isEmpty {
444
+ fileName = (path as NSString).deletingPathExtension
445
+ fileExtension = stripURLSuffix(modelType)
446
+ } else {
447
+ let normalizedPath = stripURLSuffix(path)
448
+ fileName = (normalizedPath as NSString).deletingPathExtension
449
+ fileExtension = (normalizedPath as NSString).pathExtension
780
450
  }
781
- try Self.requireInstanceMethod(Self.runnerInitSelector, on: runnerClass)
782
- try Self.requireInstanceMethod(Self.runnerLoadSelector, on: runnerClass)
783
-
784
- let alloc: ExecuTorchAllocFunction = try Self.objcMessage()
785
- let initRunner: ExecuTorchInitRunnerFunction = try Self.objcMessage()
786
- let load: ExecuTorchLoadFunction = try Self.objcMessage()
787
-
788
- let allocatedRunner = alloc(runnerClass, NSSelectorFromString("alloc")).takeRetainedValue()
789
- runner = initRunner(
790
- allocatedRunner,
791
- Self.runnerInitSelector,
792
- modelPath as NSString,
793
- tokenizerPath as NSString,
794
- specialTokens as NSArray
795
- ).takeRetainedValue()
796
- self.sequenceLength = sequenceLength
797
- self.temperature = temperature
798
-
799
- var loadError: NSError?
800
- guard load(runner, Self.runnerLoadSelector, &loadError) else {
801
- throw loadError ?? Self.error("ExecuTorch model failed to load")
451
+
452
+ let fallbackExtension = fileExtension.isEmpty ? "bin" : fileExtension
453
+ guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fallbackExtension) else {
454
+ throw NSError(
455
+ domain: "CapgoLLM",
456
+ code: 404,
457
+ userInfo: [NSLocalizedDescriptionKey: "Model file not found in bundle: \(path)"]
458
+ )
802
459
  }
460
+
461
+ return URL(fileURLWithPath: bundlePath)
803
462
  }
463
+ }
804
464
 
805
- func generate(_ prompt: String, tokenCallback: @escaping (String) -> Void) throws {
806
- guard let configClass = Self.findClass("ExecuTorchLLMConfig") else {
807
- throw Self.error("ExecuTorchLLMConfig was not found. Link ExecuTorch in the app target.")
465
+ #if canImport(FoundationModels)
466
+ private extension LLMPlugin {
467
+ @available(iOS 26.0, *)
468
+ func validateAppleAvailability(model: SystemLanguageModel, call: CAPPluginCall) -> Bool {
469
+ switch model.availability {
470
+ case .available:
471
+ return true
472
+ case .unavailable(.deviceNotEligible):
473
+ call.reject("error deviceNotEligible error")
474
+ case .unavailable(.appleIntelligenceNotEnabled):
475
+ call.reject("error appleIntelligenceNotEnabled error")
476
+ case .unavailable(.modelNotReady):
477
+ call.reject("error modelNotReady error")
478
+ case .unavailable(let other):
479
+ call.reject("error \(other) error")
808
480
  }
809
- guard let runnerClass = object_getClass(runner) else {
810
- throw Self.error("ExecuTorch runner class was not found")
481
+
482
+ return false
483
+ }
484
+
485
+ @available(iOS 26.0, *)
486
+ func streamAppleResponse(chatId: String, message: String, chat: LanguageModelSession, call: CAPPluginCall) {
487
+ Task {
488
+ do {
489
+ let stream = chat.streamResponse(to: message)
490
+ for try await chunk in stream {
491
+ let textChunk = extractAppleTextChunk(from: chunk)
492
+ notifyListeners("textFromAi", data: [
493
+ "chatId": chatId,
494
+ "text": textChunk,
495
+ "isChunk": true
496
+ ])
497
+ }
498
+ notifyListeners("aiFinished", data: ["chatId": chatId])
499
+ call.resolve()
500
+ } catch {
501
+ notifyListeners("generationError", data: [
502
+ "chatId": chatId,
503
+ "error": error.localizedDescription
504
+ ])
505
+ call.reject("Failed to get response: \(error.localizedDescription)")
506
+ }
811
507
  }
812
- try Self.requireInstanceMethod(Self.runnerResetSelector, on: runnerClass)
813
- try Self.requireInstanceMethod(Self.runnerGenerateSelector, on: runnerClass)
814
- try Self.requireInstanceMethod(Self.configInitSelector, on: configClass)
508
+ }
815
509
 
816
- let sendVoid: ExecuTorchVoidFunction = try Self.objcMessage()
817
- let alloc: ExecuTorchAllocFunction = try Self.objcMessage()
818
- let initConfig: ExecuTorchInitConfigFunction = try Self.objcMessage()
819
- let generate: ExecuTorchGenerateFunction = try Self.objcMessage()
510
+ @available(iOS 26.0, *)
511
+ func extractAppleTextChunk(from chunk: Any) -> String {
512
+ let mirror = Mirror(reflecting: chunk)
820
513
 
821
- sendVoid(runner, Self.runnerResetSelector)
514
+ if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
515
+ let content = contentProperty.value as? String {
516
+ return content
517
+ }
822
518
 
823
- let allocatedConfig = alloc(configClass, NSSelectorFromString("alloc")).takeRetainedValue()
824
- let configBlock: ExecuTorchConfigBlock = { [temperature, sequenceLength] config in
825
- config.setValue(temperature, forKey: "temperature")
826
- config.setValue(sequenceLength, forKey: "sequenceLength")
519
+ if let rawContentProperty = mirror.children.first(where: { $0.label == "rawContent" }),
520
+ let rawContent = rawContentProperty.value as? String {
521
+ return rawContent
827
522
  }
828
- let config = initConfig(
829
- allocatedConfig,
830
- Self.configInitSelector,
831
- configBlock
832
- ).takeRetainedValue()
833
-
834
- let callback: ExecuTorchTokenCallback = { token in
835
- tokenCallback(token as String)
523
+
524
+ return "\(chunk)"
525
+ }
526
+ }
527
+ #endif
528
+
529
+ #if canImport(LiteRTLM)
530
+ private extension LLMPlugin {
531
+ func initializeLiteRtEngine(modelURL: URL, maxTokens: Int) async throws -> LiteRTLM.Engine {
532
+ guard let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
533
+ throw NSError(
534
+ domain: "CapgoLLM",
535
+ code: 500,
536
+ userInfo: [NSLocalizedDescriptionKey: "Could not access the iOS caches directory for LiteRT-LM."]
537
+ )
836
538
  }
837
- var generateError: NSError?
838
- guard generate(
839
- runner,
840
- Self.runnerGenerateSelector,
841
- prompt as NSString,
842
- config,
843
- callback,
844
- &generateError
845
- ) else {
846
- throw generateError ?? Self.error("ExecuTorch generation failed")
539
+
540
+ let configs = [
541
+ try LiteRTLM.EngineConfig(
542
+ modelPath: modelURL.path,
543
+ backend: .gpu,
544
+ maxNumTokens: maxTokens,
545
+ cacheDir: cacheDirectory.path
546
+ ),
547
+ try LiteRTLM.EngineConfig(
548
+ modelPath: modelURL.path,
549
+ backend: .cpu(),
550
+ maxNumTokens: maxTokens,
551
+ cacheDir: cacheDirectory.path
552
+ )
553
+ ]
554
+
555
+ var lastError: Error?
556
+ for config in configs {
557
+ let engine = LiteRTLM.Engine(engineConfig: config)
558
+ do {
559
+ try await engine.initialize()
560
+ return engine
561
+ } catch {
562
+ lastError = error
563
+ }
847
564
  }
565
+
566
+ throw lastError ?? NSError(
567
+ domain: "CapgoLLM",
568
+ code: 500,
569
+ userInfo: [NSLocalizedDescriptionKey: "Failed to initialize LiteRT-LM on iOS."]
570
+ )
848
571
  }
849
572
 
850
- private static func objcMessage<Function>() throws -> Function {
851
- guard let handle = dlopen(nil, RTLD_NOW), let symbol = dlsym(handle, "objc_msgSend") else {
852
- throw error("Unable to resolve objc_msgSend")
573
+ func makeLiteRtConversationConfig(instructions: String?) throws -> LiteRTLM.ConversationConfig {
574
+ let sampler = try LiteRTLM.SamplerConfig(
575
+ topK: liteRtTopk,
576
+ topP: liteRtTopP,
577
+ temperature: liteRtTemperature,
578
+ seed: liteRtRandomSeed
579
+ )
580
+
581
+ let systemMessage: LiteRTLM.Message?
582
+ if let instructions, !instructions.isEmpty {
583
+ systemMessage = LiteRTLM.Message(instructions, role: .system)
584
+ } else {
585
+ systemMessage = nil
853
586
  }
854
- return unsafeBitCast(symbol, to: Function.self)
587
+
588
+ return LiteRTLM.ConversationConfig(
589
+ systemMessage: systemMessage,
590
+ samplerConfig: sampler
591
+ )
855
592
  }
856
593
 
857
- private static func requireInstanceMethod(_ selector: Selector, on cls: AnyClass) throws {
858
- guard hasInstanceMethod(cls, selector) else {
859
- throw error("ExecuTorch API selector missing: \(NSStringFromSelector(selector))")
594
+ func streamLiteRtResponse(chatId: String, message: String, conversation: LiteRTLM.Conversation, call: CAPPluginCall) {
595
+ Task {
596
+ do {
597
+ for try await chunk in conversation.sendMessageStream(LiteRTLM.Message(message)) {
598
+ let textChunk = chunk.toString
599
+ if !textChunk.isEmpty {
600
+ notifyListeners("textFromAi", data: [
601
+ "chatId": chatId,
602
+ "text": textChunk,
603
+ "isChunk": true
604
+ ])
605
+ }
606
+ }
607
+
608
+ notifyListeners("aiFinished", data: ["chatId": chatId])
609
+ call.resolve()
610
+ } catch {
611
+ notifyListeners("generationError", data: [
612
+ "chatId": chatId,
613
+ "error": error.localizedDescription
614
+ ])
615
+ call.reject("Failed to generate response: \(error.localizedDescription)")
616
+ }
860
617
  }
861
618
  }
619
+ }
620
+ #endif
862
621
 
863
- private static func hasInstanceMethod(_ cls: AnyClass, _ selector: Selector) -> Bool {
864
- class_getInstanceMethod(cls, selector) != nil
622
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
623
+ private extension LLMPlugin {
624
+ func makeMediaPipeSessionOptions() -> LlmInference.Session.Options {
625
+ let options = LlmInference.Session.Options()
626
+ options.topk = mediaPipeTopk
627
+ options.temperature = mediaPipeTemperature
628
+ options.randomSeed = mediaPipeRandomSeed
629
+ return options
865
630
  }
866
631
 
867
- private static func findClass(_ name: String) -> AnyClass? {
868
- NSClassFromString(name) ?? NSClassFromString("ExecuTorchLLM.\(name)")
869
- }
632
+ func streamMediaPipeResponse(chatId: String, message: String, session: LlmInference.Session, call: CAPPluginCall) {
633
+ Task {
634
+ do {
635
+ try session.addQueryChunk(inputText: message)
636
+ let resultStream = session.generateResponseAsync()
637
+
638
+ for try await partialResult in resultStream {
639
+ notifyListeners("textFromAi", data: [
640
+ "chatId": chatId,
641
+ "text": partialResult,
642
+ "isChunk": true
643
+ ])
644
+ }
870
645
 
871
- private static func error(_ description: String) -> NSError {
872
- NSError(domain: "CapgoLLM.ExecuTorch", code: 1, userInfo: [NSLocalizedDescriptionKey: description])
646
+ notifyListeners("aiFinished", data: ["chatId": chatId])
647
+ call.resolve()
648
+ } catch {
649
+ notifyListeners("generationError", data: [
650
+ "chatId": chatId,
651
+ "error": error.localizedDescription
652
+ ])
653
+ call.reject("Failed to generate response: \(error.localizedDescription)")
654
+ }
655
+ }
873
656
  }
874
657
  }
658
+ #endif
875
659
 
876
- // Download delegate class to handle progress updates
877
660
  class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
878
661
  weak var plugin: CAPPlugin?
879
662
 
@@ -884,7 +667,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
884
667
  func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
885
668
  didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
886
669
  totalBytesExpectedToWrite: Int64) {
887
-
888
670
  if totalBytesExpectedToWrite > 0 {
889
671
  let progress = Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100)
890
672
 
@@ -898,7 +680,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
898
680
 
899
681
  func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
900
682
  didFinishDownloadingTo location: URL) {
901
- // Handled in the completion handler
683
+ // Handled in the completion handler.
902
684
  }
903
-
904
685
  }