@capgo/capacitor-llm 7.1.2

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.
@@ -0,0 +1,469 @@
1
+ import Foundation
2
+ import Capacitor
3
+ #if canImport(FoundationModels)
4
+ import FoundationModels
5
+ #endif
6
+ // For CocoaPods, MediaPipeTasksGenAI should always be available
7
+ // For SPM, we need conditional import when SPM support is added
8
+ #if canImport(MediaPipeTasksGenAI)
9
+ import MediaPipeTasksGenAI
10
+ #endif
11
+
12
+ /**
13
+ * Please read the Capacitor iOS Plugin Development Guide
14
+ * here: https://capacitorjs.com/docs/plugins/ios
15
+ */
16
+
17
+ @objc(LLMPlugin)
18
+ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
19
+ public let identifier = "LLMPlugin"
20
+ public let jsName = "CapgoLLM"
21
+ public var pluginMethods: [CAPPluginMethod] = [
22
+ CAPPluginMethod(name: "createChat", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "sendMessage", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "getReadiness", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "setModel", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "downloadModel", returnType: CAPPluginReturnPromise)
27
+ ]
28
+
29
+ private enum ModelType {
30
+ case appleIntelligence
31
+ case mediaPipe
32
+ }
33
+
34
+ private var currentModelType: ModelType = .appleIntelligence
35
+ private var isReady = false
36
+ private var modelPath: String?
37
+
38
+ #if canImport(FoundationModels)
39
+ private var appleModel: Any? = {
40
+ if #available(iOS 26.0, *) {
41
+ return SystemLanguageModel.default
42
+ } else {
43
+ return nil
44
+ }
45
+ }()
46
+
47
+ private var appleChats: [String: Any] = [:]
48
+ #endif
49
+
50
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
51
+ private var llmInference: LlmInference?
52
+ #endif
53
+
54
+ @objc func setModel(_ call: CAPPluginCall) {
55
+ guard let path = call.getString("path") else {
56
+ call.reject("Path is required")
57
+ return
58
+ }
59
+
60
+ print("[CapgoLLM] Setting model path: \(path)")
61
+
62
+ // Extract model parameters
63
+ let maxTokens = call.getInt("maxTokens") ?? 2048
64
+ let topk = call.getInt("topk") ?? 40
65
+ let temperature = call.getFloat("temperature") ?? 0.8
66
+ let modelType = call.getString("modelType") // Optional model type parameter
67
+
68
+ modelPath = path
69
+ isReady = false
70
+
71
+ // Check if user wants to use Apple Intelligence
72
+ if path.lowercased() == "apple intelligence" {
73
+ print("[CapgoLLM] Switching to Apple Intelligence")
74
+ currentModelType = .appleIntelligence
75
+ isReady = true
76
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
77
+ call.resolve()
78
+ return
79
+ }
80
+
81
+ #if canImport(MediaPipeTasksGenAI)
82
+ Task {
83
+ do {
84
+ let modelURL: URL
85
+
86
+ // Check if it's a path within the app bundle
87
+ if path.hasPrefix("/") {
88
+ modelURL = URL(fileURLWithPath: path)
89
+ print("[CapgoLLM] Using absolute path: \(modelURL.path)")
90
+ } else {
91
+ // Try to find it in the app bundle
92
+ let fileName: String
93
+ let fileExtension: String
94
+
95
+ // If modelType is provided, use it as the extension
96
+ if let providedType = modelType, !providedType.isEmpty {
97
+ // Remove any extension from path and use provided type
98
+ fileName = (path as NSString).deletingPathExtension
99
+ fileExtension = providedType
100
+ print("[CapgoLLM] Using provided model type: \(fileExtension)")
101
+ } else {
102
+ // Extract from path
103
+ fileName = (path as NSString).deletingPathExtension
104
+ fileExtension = (path as NSString).pathExtension
105
+ }
106
+
107
+ print("[CapgoLLM] Looking for model in bundle: \(fileName) with type: \(fileExtension.isEmpty ? "bin" : fileExtension)")
108
+
109
+ // Use Bundle.main.path as shown in MediaPipe docs
110
+ guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fileExtension.isEmpty ? "bin" : fileExtension) else {
111
+ print("[CapgoLLM] Error: Model file not found in bundle: \(path)")
112
+ print("[CapgoLLM] Tried resource: '\(fileName)' with type: '\(fileExtension.isEmpty ? "bin" : fileExtension)'")
113
+ call.reject("Model file not found in bundle: \(path)")
114
+ return
115
+ }
116
+ modelURL = URL(fileURLWithPath: bundlePath)
117
+ print("[CapgoLLM] Found model in bundle at path: \(bundlePath)")
118
+
119
+ // For MediaPipe models, verify companion .litertlm file exists
120
+ if fileExtension == "task" {
121
+ let companionPath = Bundle.main.path(forResource: fileName, ofType: "litertlm")
122
+ if companionPath == nil {
123
+ print("[CapgoLLM] Warning: Companion .litertlm file not found for \(fileName)")
124
+ // Don't fail here as some models might not need it
125
+ } else {
126
+ print("[CapgoLLM] Found companion file at path: \(companionPath!)")
127
+ }
128
+ }
129
+ }
130
+
131
+ // Initialize MediaPipe LLM
132
+ print("[CapgoLLM] Initializing MediaPipe with model at: \(modelURL.path)")
133
+ let options = LlmInference.Options(modelPath: modelURL.path)
134
+ options.maxTokens = maxTokens
135
+ options.maxTopk = topk
136
+
137
+ print("[CapgoLLM] Creating LlmInference instance with maxTokens: \(maxTokens), topk: \(topk), temperature: \(temperature)")
138
+ llmInference = try LlmInference(options: options)
139
+ currentModelType = .mediaPipe
140
+ isReady = true
141
+
142
+ print("[CapgoLLM] Model loaded successfully")
143
+ notifyListeners("readinessChange", data: ["readiness": "ready"])
144
+ call.resolve()
145
+ } catch {
146
+ print("[CapgoLLM] Error loading model: \(error.localizedDescription)")
147
+ notifyListeners("readinessChange", data: ["readiness": "Failed to load model: \(error.localizedDescription)"])
148
+ call.reject("Failed to load model: \(error.localizedDescription)")
149
+ }
150
+ }
151
+ #else
152
+ call.reject("MediaPipe is not available. Please install via CocoaPods.")
153
+ #endif
154
+ }
155
+
156
+ @objc func createChat(_ call: CAPPluginCall) {
157
+ print("[CapgoLLM] Creating chat with model type: \(currentModelType)")
158
+ switch currentModelType {
159
+ case .appleIntelligence:
160
+ #if canImport(FoundationModels)
161
+ if #available(iOS 26.0, *) {
162
+ let instructions = call.getString("instructions")
163
+ let session = LanguageModelSession(instructions: instructions)
164
+ let id = UUID()
165
+ appleChats[id.uuidString] = session
166
+ call.resolve([
167
+ "id": id.uuidString
168
+ ])
169
+ } else {
170
+ call.reject("Apple Intelligence requires iOS 26.0 or later")
171
+ }
172
+ #else
173
+ call.reject("Apple Intelligence is not available on this device")
174
+ #endif
175
+
176
+ case .mediaPipe:
177
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
178
+ guard llmInference != nil else {
179
+ print("[CapgoLLM] Error: Model not loaded when creating chat")
180
+ call.reject("Model not loaded")
181
+ return
182
+ }
183
+
184
+ // MediaPipe doesn't use sessions, just return a dummy ID
185
+ let id = UUID().uuidString
186
+ print("[CapgoLLM] Created MediaPipe chat with ID: \(id)")
187
+ call.resolve([
188
+ "id": id
189
+ ])
190
+ #else
191
+ call.reject("MediaPipe is not available. Please install via CocoaPods.")
192
+ #endif
193
+ }
194
+ }
195
+
196
+ @objc func getReadiness(_ call: CAPPluginCall) {
197
+ let readiness = getReadinessStatus()
198
+ call.resolve(["readiness": readiness])
199
+ // Also notify listeners
200
+ notifyListeners("readinessChange", data: ["readiness": readiness])
201
+ }
202
+
203
+ private func getReadinessStatus() -> String {
204
+ switch currentModelType {
205
+ case .appleIntelligence:
206
+ #if canImport(FoundationModels)
207
+ if #available(iOS 26.0, *), let model = appleModel as? SystemLanguageModel {
208
+ switch model.availability {
209
+ case .available:
210
+ return "ready"
211
+ case .unavailable(.deviceNotEligible):
212
+ return "Device is not eligible for Apple Intelligence"
213
+ case .unavailable(.appleIntelligenceNotEnabled):
214
+ return "Apple Intelligence is not enabled"
215
+ case .unavailable(.modelNotReady):
216
+ return "Model is not ready"
217
+ case .unavailable(let other):
218
+ return "Error: \(other)"
219
+ }
220
+ } else {
221
+ return "Apple Intelligence requires iOS 26.0 or later"
222
+ }
223
+ #else
224
+ return "Apple Intelligence is not available on this device"
225
+ #endif
226
+
227
+ case .mediaPipe:
228
+ return isReady ? "ready" : "not_ready"
229
+ }
230
+ }
231
+
232
+ @objc func sendMessage(_ call: CAPPluginCall) {
233
+ let chatId = call.getString("chatId", "")
234
+ guard let message = call.getString("message") else {
235
+ call.reject("message not found")
236
+ return
237
+ }
238
+
239
+ switch currentModelType {
240
+ case .appleIntelligence:
241
+ #if canImport(FoundationModels)
242
+ if #available(iOS 26.0, *), let model = appleModel as? SystemLanguageModel {
243
+ switch model.availability {
244
+ case .available:
245
+ break
246
+ case .unavailable(.deviceNotEligible):
247
+ call.reject("error deviceNotEligible error")
248
+ return
249
+ case .unavailable(.appleIntelligenceNotEnabled):
250
+ call.reject("error appleIntelligenceNotEnabled error")
251
+ return
252
+ case .unavailable(.modelNotReady):
253
+ call.reject("error modelNotReady error")
254
+ return
255
+ case .unavailable(let other):
256
+ call.reject("error \(other) error")
257
+ return
258
+ }
259
+
260
+ guard let chat = appleChats[chatId] as? LanguageModelSession else {
261
+ call.reject("chat not found")
262
+ return
263
+ }
264
+
265
+ if chat.isResponding {
266
+ call.reject("chat is responding, please wait before asking new questions")
267
+ return
268
+ }
269
+
270
+ Task {
271
+ do {
272
+ let stream = chat.streamResponse(to: message)
273
+ for try await chunk in stream {
274
+ print("[CapgoLLM] Chunk type: \(type(of: chunk))")
275
+
276
+ // Extract content from Snapshot object using Mirror reflection
277
+ let textChunk: String
278
+ let mirror = Mirror(reflecting: chunk)
279
+
280
+ // Try to find content property
281
+ if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
282
+ let content = contentProperty.value as? String {
283
+ textChunk = content
284
+ print("[CapgoLLM] Extracted content: \(content)")
285
+ } else {
286
+ // Fallback: try rawContent
287
+ if let rawContentProperty = mirror.children.first(where: { $0.label == "rawContent" }),
288
+ let rawContent = rawContentProperty.value as? String {
289
+ textChunk = rawContent
290
+ print("[CapgoLLM] Extracted rawContent: \(rawContent)")
291
+ } else {
292
+ // Final fallback
293
+ textChunk = "\(chunk)"
294
+ print("[CapgoLLM] Fallback to string conversion: \(textChunk)")
295
+ }
296
+ }
297
+ let data: [String: Any] = [
298
+ "chatId": chatId as String,
299
+ "text": textChunk as String,
300
+ "isChunk": true
301
+ ]
302
+ self.notifyListeners("textFromAi", data: data)
303
+ }
304
+ let finishData: [String: Any] = ["chatId": chatId as String]
305
+ self.notifyListeners("aiFinished", data: finishData)
306
+ call.resolve()
307
+ } catch {
308
+ call.reject("Failed to get response: \(error.localizedDescription)")
309
+ }
310
+ }
311
+ } else {
312
+ call.reject("Apple Intelligence requires iOS 26.0 or later")
313
+ }
314
+ #else
315
+ call.reject("Apple Intelligence is not available on this device")
316
+ #endif
317
+
318
+ case .mediaPipe:
319
+ #if COCOAPODS || canImport(MediaPipeTasksGenAI)
320
+ guard let inference = llmInference else {
321
+ call.reject("Model not loaded")
322
+ return
323
+ }
324
+
325
+ Task {
326
+ do {
327
+ print("[CapgoLLM] Attempting to generate streaming response for message: \(message)")
328
+
329
+ // Use native streaming API
330
+ let resultStream = inference.generateResponseAsync(inputText: message)
331
+
332
+ var fullResponse = ""
333
+
334
+ for try await partialResult in resultStream {
335
+ print("[CapgoLLM] Partial result: \(partialResult)")
336
+
337
+ // Send the chunk
338
+ self.notifyListeners("textFromAi", data: [
339
+ "chatId": chatId,
340
+ "text": partialResult,
341
+ "isChunk": true
342
+ ])
343
+
344
+ // Accumulate for history (if needed)
345
+ fullResponse += partialResult
346
+ }
347
+
348
+ print("[CapgoLLM] Streaming complete. Full response: \(fullResponse)")
349
+
350
+ self.notifyListeners("aiFinished", data: ["chatId": chatId])
351
+ call.resolve()
352
+
353
+ } catch {
354
+ print("[CapgoLLM] Error details: \(error)")
355
+ call.reject("Failed to generate response: \(error.localizedDescription)")
356
+ }
357
+ }
358
+ #else
359
+ call.reject("MediaPipe is not available. Please install via CocoaPods.")
360
+ #endif
361
+ }
362
+ }
363
+
364
+ @objc func downloadModel(_ call: CAPPluginCall) {
365
+ guard let urlString = call.getString("url") else {
366
+ call.reject("URL is required")
367
+ return
368
+ }
369
+
370
+ guard let url = URL(string: urlString) else {
371
+ call.reject("Invalid URL")
372
+ return
373
+ }
374
+
375
+ let filename = call.getString("filename") ?? url.lastPathComponent
376
+
377
+ // Get documents directory
378
+ guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
379
+ call.reject("Could not access documents directory")
380
+ return
381
+ }
382
+
383
+ let destinationURL = documentsPath.appendingPathComponent(filename)
384
+
385
+ // Create download task
386
+ let session = URLSession(configuration: .default, delegate: DownloadDelegate(plugin: self), delegateQueue: nil)
387
+ let downloadTask = session.downloadTask(with: url) { [weak self] (tempURL, _, error) in
388
+ guard let self = self else { return }
389
+
390
+ if let error = error {
391
+ call.reject("Download failed: \(error.localizedDescription)")
392
+ return
393
+ }
394
+
395
+ guard let tempURL = tempURL else {
396
+ call.reject("Download failed: No temporary file")
397
+ return
398
+ }
399
+
400
+ do {
401
+ // Remove existing file if it exists
402
+ if FileManager.default.fileExists(atPath: destinationURL.path) {
403
+ try FileManager.default.removeItem(at: destinationURL)
404
+ }
405
+
406
+ // Move downloaded file to documents directory
407
+ try FileManager.default.moveItem(at: tempURL, to: destinationURL)
408
+
409
+ var result = [
410
+ "path": destinationURL.path
411
+ ]
412
+
413
+ // Handle companion URL if provided (for compatibility)
414
+ if let companionUrlString = call.getString("companionUrl"),
415
+ let companionUrl = URL(string: companionUrlString) {
416
+
417
+ let companionFilename = companionUrl.lastPathComponent
418
+ let companionDestination = documentsPath.appendingPathComponent(companionFilename)
419
+
420
+ // Download companion file synchronously for simplicity
421
+ do {
422
+ let companionData = try Data(contentsOf: companionUrl)
423
+ try companionData.write(to: companionDestination)
424
+ result["companionPath"] = companionDestination.path
425
+ } catch {
426
+ // Log error but don't fail the whole download
427
+ print("Failed to download companion file: \(error)")
428
+ }
429
+ }
430
+
431
+ call.resolve(result)
432
+
433
+ } catch {
434
+ call.reject("Failed to save file: \(error.localizedDescription)")
435
+ }
436
+ }
437
+
438
+ downloadTask.resume()
439
+ }
440
+ }
441
+
442
+ // Download delegate class to handle progress updates
443
+ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
444
+ weak var plugin: CAPPlugin?
445
+
446
+ init(plugin: CAPPlugin) {
447
+ self.plugin = plugin
448
+ }
449
+
450
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
451
+ didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
452
+ totalBytesExpectedToWrite: Int64) {
453
+
454
+ if totalBytesExpectedToWrite > 0 {
455
+ let progress = Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100)
456
+
457
+ plugin?.notifyListeners("downloadProgress", data: [
458
+ "progress": progress,
459
+ "downloadedBytes": totalBytesWritten,
460
+ "totalBytes": totalBytesExpectedToWrite
461
+ ])
462
+ }
463
+ }
464
+
465
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
466
+ didFinishDownloadingTo location: URL) {
467
+ // Handled in the completion handler
468
+ }
469
+ }
@@ -0,0 +1,15 @@
1
+ import XCTest
2
+ @testable import LLMPlugin
3
+
4
+ class LLMPluginTests: XCTestCase {
5
+ func testEcho() {
6
+ // This is an example of a functional test case for a plugin.
7
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
8
+
9
+ let plugin = LLMPlugin()
10
+
11
+ let result = plugin.echo(value: "Hello, World!")
12
+
13
+ XCTAssertEqual(result.value, "Hello, World!")
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@capgo/capacitor-llm",
3
+ "version": "7.1.2",
4
+ "description": "Adds support for LLM locally runned for Capacitor",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapgoLLM.podspec"
17
+ ],
18
+ "author": "WcaleNieWolny <michael@capgo.app>",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Cap-go/llm.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Cap-go/llm/issues"
26
+ },
27
+ "keywords": [
28
+ "capacitor",
29
+ "plugin",
30
+ "native",
31
+ "llm",
32
+ "local",
33
+ "gemma",
34
+ "gemma3",
35
+ "gemma3-270m",
36
+ "gemma2-2b",
37
+ "apple-intelligence",
38
+ "gpt"
39
+ ],
40
+ "scripts": {
41
+ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
42
+ "verify:ios": "xcodebuild -scheme CapgoLLM -destination generic/platform=iOS",
43
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
44
+ "verify:web": "npm run build",
45
+ "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
46
+ "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
47
+ "eslint": "eslint . --ext ts",
48
+ "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
49
+ "swiftlint": "node-swiftlint",
50
+ "docgen": "docgen --api LLMPlugin --output-readme README.md --output-json dist/docs.json",
51
+ "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
52
+ "clean": "rimraf ./dist",
53
+ "watch": "tsc --watch",
54
+ "prepublishOnly": "npm run build"
55
+ },
56
+ "devDependencies": {
57
+ "@capacitor/android": "^7.0.0",
58
+ "@capacitor/core": "^7.0.0",
59
+ "@capacitor/docgen": "^0.3.0",
60
+ "@capacitor/ios": "^7.0.0",
61
+ "@ionic/eslint-config": "^0.4.0",
62
+ "@ionic/prettier-config": "^4.0.0",
63
+ "@ionic/swiftlint-config": "^2.0.0",
64
+ "@webgpu/types": "^0.1.64",
65
+ "eslint": "^8.57.0",
66
+ "google-protobuf": "^3.21.4",
67
+ "prettier": "^3.4.2",
68
+ "prettier-plugin-java": "^2.6.6",
69
+ "rimraf": "^6.0.1",
70
+ "rollup": "^4.30.1",
71
+ "swiftlint": "^2.0.0",
72
+ "typescript": "~4.1.5"
73
+ },
74
+ "peerDependencies": {
75
+ "@capacitor/core": ">=7.0.0",
76
+ "@mediapipe/tasks-genai": "^0.10.24"
77
+ },
78
+ "prettier": "@ionic/prettier-config",
79
+ "swiftlint": "@ionic/swiftlint-config",
80
+ "eslintConfig": {
81
+ "extends": "@ionic/eslint-config/recommended"
82
+ },
83
+ "capacitor": {
84
+ "ios": {
85
+ "src": "ios"
86
+ },
87
+ "android": {
88
+ "src": "android"
89
+ }
90
+ },
91
+ "dependencies": {}
92
+ }