@capgo/capacitor-llm 8.0.11 → 8.0.13

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,5 +1,7 @@
1
1
  import Foundation
2
2
  import Capacitor
3
+ import ObjectiveC
4
+ import Darwin
3
5
  #if canImport(FoundationModels)
4
6
  import FoundationModels
5
7
  #endif
@@ -16,7 +18,7 @@ import MediaPipeTasksGenAI
16
18
 
17
19
  @objc(LLMPlugin)
18
20
  public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
19
- private let pluginVersion: String = "8.0.11"
21
+ private let pluginVersion: String = "8.0.13"
20
22
  #if targetEnvironment(macCatalyst)
21
23
  private let catalystNotSupportedMessage = "CapgoLLM is not available on Mac Catalyst. Use an iOS/iPadOS target instead."
22
24
  #endif
@@ -34,6 +36,23 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
34
36
  private enum ModelType {
35
37
  case appleIntelligence
36
38
  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
+ }
37
56
  }
38
57
 
39
58
  private var currentModelType: ModelType = .appleIntelligence
@@ -56,6 +75,14 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
56
75
  private var llmInference: LlmInference?
57
76
  #endif
58
77
 
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
85
+
59
86
  @objc func setModel(_ call: CAPPluginCall) {
60
87
  guard let path = call.getString("path") else {
61
88
  call.reject("Path is required")
@@ -74,13 +101,33 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
74
101
  let topk = call.getInt("topk") ?? 40
75
102
  let temperature = call.getFloat("temperature") ?? 0.8
76
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
+ }
77
118
 
78
119
  modelPath = path
120
+ execuTorchChats.removeAll()
121
+ #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
122
+ llmInference = nil
123
+ #endif
124
+ execuTorchRunner = nil
79
125
  isReady = false
80
126
 
81
127
  // Check if user wants to use Apple Intelligence
82
- if path.lowercased() == "apple intelligence" {
128
+ if engine == "apple" || path.lowercased() == "apple intelligence" {
83
129
  print("[CapgoLLM] Switching to Apple Intelligence")
130
+ execuTorchRunner = nil
84
131
  currentModelType = .appleIntelligence
85
132
  isReady = true
86
133
  notifyListeners("readinessChange", data: ["readiness": "ready"])
@@ -88,7 +135,22 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
88
135
  return
89
136
  }
90
137
 
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
+
91
152
  #if !targetEnvironment(macCatalyst) && canImport(MediaPipeTasksGenAI)
153
+ currentModelType = .mediaPipe
92
154
  Task {
93
155
  do {
94
156
  let modelURL: URL
@@ -153,6 +215,8 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
153
215
  notifyListeners("readinessChange", data: ["readiness": "ready"])
154
216
  call.resolve()
155
217
  } catch {
218
+ llmInference = nil
219
+ isReady = false
156
220
  print("[CapgoLLM] Error loading model: \(error.localizedDescription)")
157
221
  notifyListeners("readinessChange", data: ["readiness": "Failed to load model: \(error.localizedDescription)"])
158
222
  call.reject("Failed to load model: \(error.localizedDescription)")
@@ -163,6 +227,108 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
163
227
  #endif
164
228
  }
165
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
+ }
273
+
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)")
300
+ }
301
+ }
302
+ }
303
+
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
+
166
332
  @objc func createChat(_ call: CAPPluginCall) {
167
333
  #if targetEnvironment(macCatalyst)
168
334
  call.reject(catalystNotSupportedMessage)
@@ -205,6 +371,18 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
205
371
  #else
206
372
  call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
207
373
  #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
+ ])
208
386
  }
209
387
  }
210
388
 
@@ -247,6 +425,9 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
247
425
 
248
426
  case .mediaPipe:
249
427
  return isReady ? "ready" : "not_ready"
428
+
429
+ case .execuTorch:
430
+ return isReady ? "ready" : "not_ready"
250
431
  }
251
432
  }
252
433
 
@@ -384,7 +565,71 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
384
565
  #else
385
566
  call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
386
567
  #endif
568
+
569
+ case .execuTorch:
570
+ guard isReady, let runner = execuTorchRunner else {
571
+ call.reject("ExecuTorch model not loaded")
572
+ return
573
+ }
574
+ guard let chat = execuTorchChats[chatId] else {
575
+ call.reject("chat not found")
576
+ return
577
+ }
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
+
617
+ private func beginExecuTorchGeneration() -> Bool {
618
+ execuTorchGenerationLock.lock()
619
+ defer { execuTorchGenerationLock.unlock() }
620
+
621
+ if execuTorchIsGenerating {
622
+ return false
387
623
  }
624
+
625
+ execuTorchIsGenerating = true
626
+ return true
627
+ }
628
+
629
+ private func finishExecuTorchGeneration() {
630
+ execuTorchGenerationLock.lock()
631
+ execuTorchIsGenerating = false
632
+ execuTorchGenerationLock.unlock()
388
633
  }
389
634
 
390
635
  @objc func downloadModel(_ call: CAPPluginCall) {
@@ -469,6 +714,165 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
469
714
  }
470
715
  }
471
716
 
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
758
+ }
759
+
760
+ return hasInstanceMethod(runnerClass, runnerInitSelector) &&
761
+ hasInstanceMethod(runnerClass, runnerLoadSelector) &&
762
+ hasInstanceMethod(runnerClass, runnerResetSelector) &&
763
+ hasInstanceMethod(runnerClass, runnerGenerateSelector) &&
764
+ hasInstanceMethod(configClass, configInitSelector)
765
+ }
766
+
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.")
780
+ }
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")
802
+ }
803
+ }
804
+
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.")
808
+ }
809
+ guard let runnerClass = object_getClass(runner) else {
810
+ throw Self.error("ExecuTorch runner class was not found")
811
+ }
812
+ try Self.requireInstanceMethod(Self.runnerResetSelector, on: runnerClass)
813
+ try Self.requireInstanceMethod(Self.runnerGenerateSelector, on: runnerClass)
814
+ try Self.requireInstanceMethod(Self.configInitSelector, on: configClass)
815
+
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()
820
+
821
+ sendVoid(runner, Self.runnerResetSelector)
822
+
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")
827
+ }
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)
836
+ }
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")
847
+ }
848
+ }
849
+
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")
853
+ }
854
+ return unsafeBitCast(symbol, to: Function.self)
855
+ }
856
+
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))")
860
+ }
861
+ }
862
+
863
+ private static func hasInstanceMethod(_ cls: AnyClass, _ selector: Selector) -> Bool {
864
+ class_getInstanceMethod(cls, selector) != nil
865
+ }
866
+
867
+ private static func findClass(_ name: String) -> AnyClass? {
868
+ NSClassFromString(name) ?? NSClassFromString("ExecuTorchLLM.\(name)")
869
+ }
870
+
871
+ private static func error(_ description: String) -> NSError {
872
+ NSError(domain: "CapgoLLM.ExecuTorch", code: 1, userInfo: [NSLocalizedDescriptionKey: description])
873
+ }
874
+ }
875
+
472
876
  // Download delegate class to handle progress updates
473
877
  class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
474
878
  weak var plugin: CAPPlugin?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-llm",
3
- "version": "8.0.11",
3
+ "version": "8.0.13",
4
4
  "description": "Adds support for LLM locally runned for Capacitor",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",