@capgo/capacitor-llm 8.0.11 → 8.0.12

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.12"
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,11 @@ 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 var execuTorchIsGenerating = false
82
+
59
83
  @objc func setModel(_ call: CAPPluginCall) {
60
84
  guard let path = call.getString("path") else {
61
85
  call.reject("Path is required")
@@ -74,13 +98,33 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
74
98
  let topk = call.getInt("topk") ?? 40
75
99
  let temperature = call.getFloat("temperature") ?? 0.8
76
100
  let modelType = call.getString("modelType") // Optional model type parameter
101
+ let engine = call.getString("engine")?.lowercased() ?? "auto"
102
+ let tokenizerPath = call.getString("tokenizerPath")
103
+ let specialTokens = call.getArray("specialTokens", String.self) ?? []
104
+ let sequenceLength = call.getInt("sequenceLength") ?? maxTokens
105
+
106
+ guard sequenceLength > 0 else {
107
+ call.reject("sequenceLength must be > 0")
108
+ return
109
+ }
110
+
111
+ guard !isExecuTorchGenerating() else {
112
+ call.reject("Cannot switch model while ExecuTorch is generating. Wait for aiFinished.")
113
+ return
114
+ }
77
115
 
78
116
  modelPath = path
117
+ execuTorchChats.removeAll()
118
+ #if !targetEnvironment(macCatalyst) && (COCOAPODS || canImport(MediaPipeTasksGenAI))
119
+ llmInference = nil
120
+ #endif
121
+ execuTorchRunner = nil
79
122
  isReady = false
80
123
 
81
124
  // Check if user wants to use Apple Intelligence
82
- if path.lowercased() == "apple intelligence" {
125
+ if engine == "apple" || path.lowercased() == "apple intelligence" {
83
126
  print("[CapgoLLM] Switching to Apple Intelligence")
127
+ execuTorchRunner = nil
84
128
  currentModelType = .appleIntelligence
85
129
  isReady = true
86
130
  notifyListeners("readinessChange", data: ["readiness": "ready"])
@@ -88,7 +132,22 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
88
132
  return
89
133
  }
90
134
 
135
+ if shouldUseExecuTorch(engine: engine, path: path, tokenizerPath: tokenizerPath) {
136
+ currentModelType = .execuTorch
137
+ setExecuTorchModel(
138
+ call,
139
+ path: path,
140
+ tokenizerPath: tokenizerPath,
141
+ modelType: modelType,
142
+ specialTokens: specialTokens,
143
+ sequenceLength: sequenceLength,
144
+ temperature: temperature
145
+ )
146
+ return
147
+ }
148
+
91
149
  #if !targetEnvironment(macCatalyst) && canImport(MediaPipeTasksGenAI)
150
+ currentModelType = .mediaPipe
92
151
  Task {
93
152
  do {
94
153
  let modelURL: URL
@@ -153,6 +212,8 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
153
212
  notifyListeners("readinessChange", data: ["readiness": "ready"])
154
213
  call.resolve()
155
214
  } catch {
215
+ llmInference = nil
216
+ isReady = false
156
217
  print("[CapgoLLM] Error loading model: \(error.localizedDescription)")
157
218
  notifyListeners("readinessChange", data: ["readiness": "Failed to load model: \(error.localizedDescription)"])
158
219
  call.reject("Failed to load model: \(error.localizedDescription)")
@@ -163,6 +224,106 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
163
224
  #endif
164
225
  }
165
226
 
227
+ private func shouldUseExecuTorch(engine: String, path: String, tokenizerPath: String?) -> Bool {
228
+ if path.lowercased() == "apple intelligence" {
229
+ return false
230
+ }
231
+ if engine == "executorch" {
232
+ return true
233
+ }
234
+ if engine == "apple" || engine == "mediapipe" {
235
+ return false
236
+ }
237
+ if tokenizerPath?.isEmpty == false {
238
+ return true
239
+ }
240
+ return path.lowercased().hasSuffix(".pte")
241
+ }
242
+
243
+ private func setExecuTorchModel(
244
+ _ call: CAPPluginCall,
245
+ path: String,
246
+ tokenizerPath: String?,
247
+ modelType: String?,
248
+ specialTokens: [String],
249
+ sequenceLength: Int,
250
+ temperature: Float
251
+ ) {
252
+ execuTorchRunner = nil
253
+
254
+ guard sequenceLength > 0 else {
255
+ call.reject("sequenceLength must be > 0")
256
+ return
257
+ }
258
+
259
+ guard DynamicExecuTorchRunner.isAvailable else {
260
+ call.reject("ExecuTorch is not linked in this iOS app. Add the ExecuTorch Swift Package products to the app target.")
261
+ return
262
+ }
263
+
264
+ guard let tokenizerPath = tokenizerPath, !tokenizerPath.isEmpty else {
265
+ call.reject("tokenizerPath is required for ExecuTorch models")
266
+ return
267
+ }
268
+
269
+ Task {
270
+ do {
271
+ let modelURL = try resolveModelURL(path: path, modelType: modelType, defaultExtension: "pte")
272
+ let tokenizerURL = try resolveModelURL(path: tokenizerPath, modelType: nil, defaultExtension: "model")
273
+
274
+ print("[CapgoLLM] Initializing ExecuTorch with model at: \(modelURL.path)")
275
+ print("[CapgoLLM] Initializing ExecuTorch with tokenizer at: \(tokenizerURL.path)")
276
+
277
+ execuTorchRunner = try DynamicExecuTorchRunner(
278
+ modelPath: modelURL.path,
279
+ tokenizerPath: tokenizerURL.path,
280
+ specialTokens: specialTokens,
281
+ sequenceLength: sequenceLength,
282
+ temperature: Double(temperature)
283
+ )
284
+ currentModelType = .execuTorch
285
+ isReady = true
286
+
287
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
288
+ call.resolve()
289
+ } catch {
290
+ execuTorchRunner = nil
291
+ isReady = false
292
+ print("[CapgoLLM] Error loading ExecuTorch model: \(error.localizedDescription)")
293
+ notifyListeners("readinessChange", data: ["readiness": "Failed to load ExecuTorch model: \(error.localizedDescription)"])
294
+ call.reject("Failed to load ExecuTorch model: \(error.localizedDescription)")
295
+ }
296
+ }
297
+ }
298
+
299
+ private func resolveModelURL(path: String, modelType: String?, defaultExtension: String) throws -> URL {
300
+ if path.hasPrefix("/") {
301
+ return URL(fileURLWithPath: path)
302
+ }
303
+
304
+ let fileName: String
305
+ let fileExtension: String
306
+
307
+ if let providedType = modelType, !providedType.isEmpty {
308
+ fileName = (path as NSString).deletingPathExtension
309
+ fileExtension = providedType
310
+ } else {
311
+ fileName = (path as NSString).deletingPathExtension
312
+ let extractedExtension = (path as NSString).pathExtension
313
+ fileExtension = extractedExtension.isEmpty ? defaultExtension : extractedExtension
314
+ }
315
+
316
+ guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fileExtension) else {
317
+ throw NSError(
318
+ domain: "CapgoLLM",
319
+ code: 404,
320
+ userInfo: [NSLocalizedDescriptionKey: "Model file not found in bundle: \(path)"]
321
+ )
322
+ }
323
+
324
+ return URL(fileURLWithPath: bundlePath)
325
+ }
326
+
166
327
  @objc func createChat(_ call: CAPPluginCall) {
167
328
  #if targetEnvironment(macCatalyst)
168
329
  call.reject(catalystNotSupportedMessage)
@@ -205,6 +366,18 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
205
366
  #else
206
367
  call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
207
368
  #endif
369
+
370
+ case .execuTorch:
371
+ guard execuTorchRunner != nil else {
372
+ call.reject("ExecuTorch model not loaded")
373
+ return
374
+ }
375
+ let id = UUID().uuidString
376
+ execuTorchChats[id] = ExecuTorchChatSession()
377
+ print("[CapgoLLM] Created ExecuTorch chat with ID: \(id)")
378
+ call.resolve([
379
+ "id": id
380
+ ])
208
381
  }
209
382
  }
210
383
 
@@ -247,6 +420,9 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
247
420
 
248
421
  case .mediaPipe:
249
422
  return isReady ? "ready" : "not_ready"
423
+
424
+ case .execuTorch:
425
+ return isReady ? "ready" : "not_ready"
250
426
  }
251
427
  }
252
428
 
@@ -384,7 +560,71 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
384
560
  #else
385
561
  call.reject("MediaPipe is not available on this platform. Please install via CocoaPods on iOS.")
386
562
  #endif
563
+
564
+ case .execuTorch:
565
+ guard isReady, let runner = execuTorchRunner else {
566
+ call.reject("ExecuTorch model not loaded")
567
+ return
568
+ }
569
+ guard let chat = execuTorchChats[chatId] else {
570
+ call.reject("chat not found")
571
+ return
572
+ }
573
+ guard beginExecuTorchGeneration() else {
574
+ call.reject("ExecuTorch is already generating. Wait for aiFinished before sending another message.")
575
+ return
576
+ }
577
+
578
+ Task {
579
+ defer {
580
+ self.finishExecuTorchGeneration()
581
+ }
582
+
583
+ do {
584
+ let prompt = chat.buildPrompt(userMessage: message)
585
+ var fullResponse = ""
586
+ print("[CapgoLLM] Attempting to generate ExecuTorch response for message: \(message)")
587
+ try runner.generate(prompt) { token in
588
+ self.notifyListeners("textFromAi", data: [
589
+ "chatId": chatId,
590
+ "text": token,
591
+ "isChunk": true
592
+ ])
593
+ fullResponse += token
594
+ }
595
+ chat.addTurn(userMessage: message, assistantMessage: fullResponse)
596
+ self.notifyListeners("aiFinished", data: ["chatId": chatId])
597
+ call.resolve()
598
+ } catch {
599
+ print("[CapgoLLM] ExecuTorch error details: \(error)")
600
+ call.reject("Failed to generate ExecuTorch response: \(error.localizedDescription)")
601
+ }
602
+ }
603
+ }
604
+ }
605
+
606
+ private func isExecuTorchGenerating() -> Bool {
607
+ execuTorchGenerationLock.lock()
608
+ defer { execuTorchGenerationLock.unlock() }
609
+ return execuTorchIsGenerating
610
+ }
611
+
612
+ private func beginExecuTorchGeneration() -> Bool {
613
+ execuTorchGenerationLock.lock()
614
+ defer { execuTorchGenerationLock.unlock() }
615
+
616
+ if execuTorchIsGenerating {
617
+ return false
387
618
  }
619
+
620
+ execuTorchIsGenerating = true
621
+ return true
622
+ }
623
+
624
+ private func finishExecuTorchGeneration() {
625
+ execuTorchGenerationLock.lock()
626
+ execuTorchIsGenerating = false
627
+ execuTorchGenerationLock.unlock()
388
628
  }
389
629
 
390
630
  @objc func downloadModel(_ call: CAPPluginCall) {
@@ -469,6 +709,165 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
469
709
  }
470
710
  }
471
711
 
712
+ private typealias ExecuTorchAllocFunction = @convention(c) (AnyClass, Selector) -> Unmanaged<AnyObject>
713
+ private typealias ExecuTorchInitRunnerFunction = @convention(c) (
714
+ AnyObject,
715
+ Selector,
716
+ NSString,
717
+ NSString,
718
+ NSArray
719
+ ) -> Unmanaged<AnyObject>
720
+ private typealias ExecuTorchLoadFunction = @convention(c) (
721
+ AnyObject,
722
+ Selector,
723
+ UnsafeMutablePointer<NSError?>
724
+ ) -> Bool
725
+ private typealias ExecuTorchConfigBlock = @convention(block) (NSObject) -> Void
726
+ private typealias ExecuTorchInitConfigFunction = @convention(c) (
727
+ AnyObject,
728
+ Selector,
729
+ ExecuTorchConfigBlock
730
+ ) -> Unmanaged<AnyObject>
731
+ private typealias ExecuTorchTokenCallback = @convention(block) (NSString) -> Void
732
+ private typealias ExecuTorchGenerateFunction = @convention(c) (
733
+ AnyObject,
734
+ Selector,
735
+ NSString,
736
+ AnyObject,
737
+ ExecuTorchTokenCallback,
738
+ UnsafeMutablePointer<NSError?>
739
+ ) -> Bool
740
+ private typealias ExecuTorchVoidFunction = @convention(c) (AnyObject, Selector) -> Void
741
+
742
+ private final class DynamicExecuTorchRunner {
743
+ private static let runnerInitSelector = NSSelectorFromString("initWithModelPath:tokenizerPath:specialTokens:")
744
+ private static let runnerLoadSelector = NSSelectorFromString("loadWithError:")
745
+ private static let runnerResetSelector = NSSelectorFromString("reset")
746
+ private static let runnerGenerateSelector = NSSelectorFromString("generateWithPrompt:config:tokenCallback:error:")
747
+ private static let configInitSelector = NSSelectorFromString("initWithBlock:")
748
+
749
+ static var isAvailable: Bool {
750
+ guard let runnerClass = findClass("ExecuTorchLLMTextRunner"),
751
+ let configClass = findClass("ExecuTorchLLMConfig") else {
752
+ return false
753
+ }
754
+
755
+ return hasInstanceMethod(runnerClass, runnerInitSelector) &&
756
+ hasInstanceMethod(runnerClass, runnerLoadSelector) &&
757
+ hasInstanceMethod(runnerClass, runnerResetSelector) &&
758
+ hasInstanceMethod(runnerClass, runnerGenerateSelector) &&
759
+ hasInstanceMethod(configClass, configInitSelector)
760
+ }
761
+
762
+ private let runner: AnyObject
763
+ private let sequenceLength: Int
764
+ private let temperature: Double
765
+
766
+ init(
767
+ modelPath: String,
768
+ tokenizerPath: String,
769
+ specialTokens: [String],
770
+ sequenceLength: Int,
771
+ temperature: Double
772
+ ) throws {
773
+ guard let runnerClass = Self.findClass("ExecuTorchLLMTextRunner") else {
774
+ throw Self.error("ExecuTorchLLMTextRunner was not found. Link ExecuTorch in the app target.")
775
+ }
776
+ try Self.requireInstanceMethod(Self.runnerInitSelector, on: runnerClass)
777
+ try Self.requireInstanceMethod(Self.runnerLoadSelector, on: runnerClass)
778
+
779
+ let alloc: ExecuTorchAllocFunction = try Self.objcMessage()
780
+ let initRunner: ExecuTorchInitRunnerFunction = try Self.objcMessage()
781
+ let load: ExecuTorchLoadFunction = try Self.objcMessage()
782
+
783
+ let allocatedRunner = alloc(runnerClass, NSSelectorFromString("alloc")).takeRetainedValue()
784
+ runner = initRunner(
785
+ allocatedRunner,
786
+ Self.runnerInitSelector,
787
+ modelPath as NSString,
788
+ tokenizerPath as NSString,
789
+ specialTokens as NSArray
790
+ ).takeRetainedValue()
791
+ self.sequenceLength = sequenceLength
792
+ self.temperature = temperature
793
+
794
+ var loadError: NSError?
795
+ guard load(runner, Self.runnerLoadSelector, &loadError) else {
796
+ throw loadError ?? Self.error("ExecuTorch model failed to load")
797
+ }
798
+ }
799
+
800
+ func generate(_ prompt: String, tokenCallback: @escaping (String) -> Void) throws {
801
+ guard let configClass = Self.findClass("ExecuTorchLLMConfig") else {
802
+ throw Self.error("ExecuTorchLLMConfig was not found. Link ExecuTorch in the app target.")
803
+ }
804
+ guard let runnerClass = object_getClass(runner) else {
805
+ throw Self.error("ExecuTorch runner class was not found")
806
+ }
807
+ try Self.requireInstanceMethod(Self.runnerResetSelector, on: runnerClass)
808
+ try Self.requireInstanceMethod(Self.runnerGenerateSelector, on: runnerClass)
809
+ try Self.requireInstanceMethod(Self.configInitSelector, on: configClass)
810
+
811
+ let sendVoid: ExecuTorchVoidFunction = try Self.objcMessage()
812
+ let alloc: ExecuTorchAllocFunction = try Self.objcMessage()
813
+ let initConfig: ExecuTorchInitConfigFunction = try Self.objcMessage()
814
+ let generate: ExecuTorchGenerateFunction = try Self.objcMessage()
815
+
816
+ sendVoid(runner, Self.runnerResetSelector)
817
+
818
+ let allocatedConfig = alloc(configClass, NSSelectorFromString("alloc")).takeRetainedValue()
819
+ let configBlock: ExecuTorchConfigBlock = { [temperature, sequenceLength] config in
820
+ config.setValue(temperature, forKey: "temperature")
821
+ config.setValue(sequenceLength, forKey: "sequenceLength")
822
+ }
823
+ let config = initConfig(
824
+ allocatedConfig,
825
+ Self.configInitSelector,
826
+ configBlock
827
+ ).takeRetainedValue()
828
+
829
+ let callback: ExecuTorchTokenCallback = { token in
830
+ tokenCallback(token as String)
831
+ }
832
+ var generateError: NSError?
833
+ guard generate(
834
+ runner,
835
+ Self.runnerGenerateSelector,
836
+ prompt as NSString,
837
+ config,
838
+ callback,
839
+ &generateError
840
+ ) else {
841
+ throw generateError ?? Self.error("ExecuTorch generation failed")
842
+ }
843
+ }
844
+
845
+ private static func objcMessage<Function>() throws -> Function {
846
+ guard let handle = dlopen(nil, RTLD_NOW), let symbol = dlsym(handle, "objc_msgSend") else {
847
+ throw error("Unable to resolve objc_msgSend")
848
+ }
849
+ return unsafeBitCast(symbol, to: Function.self)
850
+ }
851
+
852
+ private static func requireInstanceMethod(_ selector: Selector, on cls: AnyClass) throws {
853
+ guard hasInstanceMethod(cls, selector) else {
854
+ throw error("ExecuTorch API selector missing: \(NSStringFromSelector(selector))")
855
+ }
856
+ }
857
+
858
+ private static func hasInstanceMethod(_ cls: AnyClass, _ selector: Selector) -> Bool {
859
+ class_getInstanceMethod(cls, selector) != nil
860
+ }
861
+
862
+ private static func findClass(_ name: String) -> AnyClass? {
863
+ NSClassFromString(name) ?? NSClassFromString("ExecuTorchLLM.\(name)")
864
+ }
865
+
866
+ private static func error(_ description: String) -> NSError {
867
+ NSError(domain: "CapgoLLM.ExecuTorch", code: 1, userInfo: [NSLocalizedDescriptionKey: description])
868
+ }
869
+ }
870
+
472
871
  // Download delegate class to handle progress updates
473
872
  class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
474
873
  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.12",
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",