@capgo/capacitor-llm 8.0.19 → 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.
- package/CapgoCapacitorLlm.podspec +1 -9
- package/Package.swift +21 -3
- package/README.md +137 -163
- package/android/build.gradle +3 -2
- package/android/src/main/java/ee/forgr/capgo_llm/LLM.java +605 -283
- package/android/src/main/java/ee/forgr/capgo_llm/LLMPlugin.java +36 -63
- package/dist/docs.json +66 -35
- package/dist/esm/definitions.d.ts +28 -20
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +68 -39
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +68 -39
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +68 -39
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LLMPlugin/LLMPlugin.swift +384 -613
- package/ios/Sources/LiteRTLM/Benchmark.swift +83 -0
- package/ios/Sources/LiteRTLM/Capabilities.swift +41 -0
- package/ios/Sources/LiteRTLM/Config.swift +172 -0
- package/ios/Sources/LiteRTLM/Conversation.swift +472 -0
- package/ios/Sources/LiteRTLM/Engine.swift +208 -0
- package/ios/Sources/LiteRTLM/ExperimentalFlags.swift +142 -0
- package/ios/Sources/LiteRTLM/LiteRTLMError.swift +156 -0
- package/ios/Sources/LiteRTLM/Message.swift +225 -0
- package/ios/Sources/LiteRTLM/Tool.swift +291 -0
- package/ios/Sources/LiteRTLM/ToolManager.swift +152 -0
- package/package.json +6 -5
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
@@ -61,16 +43,6 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
61
43
|
|
|
62
44
|
#if canImport(FoundationModels)
|
|
63
45
|
private var appleModel: Any? = {
|
|
64
|
-
// `SystemLanguageModel.default` is not functional for an iOS/iPadOS
|
|
65
|
-
// binary running "Designed for iPad" on Apple Silicon Mac, and
|
|
66
|
-
// touching it there crashes the app during plugin instantiation —
|
|
67
|
-
// before any of the app's own platform checks get a chance to run,
|
|
68
|
-
// since Capacitor eagerly instantiates every registered plugin at
|
|
69
|
-
// bridge startup. Skip it in that environment, same as the existing
|
|
70
|
-
// Mac Catalyst guard below.
|
|
71
|
-
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
72
|
-
return nil
|
|
73
|
-
}
|
|
74
46
|
if #available(iOS 26.0, *) {
|
|
75
47
|
return SystemLanguageModel.default
|
|
76
48
|
} else {
|
|
@@ -81,17 +53,21 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
81
53
|
private var appleChats: [String: Any] = [:]
|
|
82
54
|
#endif
|
|
83
55
|
|
|
84
|
-
#if
|
|
85
|
-
private var
|
|
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
|
|
86
62
|
#endif
|
|
87
63
|
|
|
88
|
-
|
|
89
|
-
private var
|
|
90
|
-
private
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
95
71
|
|
|
96
72
|
@objc func setModel(_ call: CAPPluginCall) {
|
|
97
73
|
guard let path = call.getString("path") else {
|
|
@@ -99,45 +75,23 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
99
75
|
return
|
|
100
76
|
}
|
|
101
77
|
|
|
102
|
-
#if targetEnvironment(macCatalyst)
|
|
103
|
-
call.reject(catalystNotSupportedMessage)
|
|
104
|
-
return
|
|
105
|
-
#endif
|
|
106
|
-
|
|
107
|
-
print("[CapgoLLM] Setting model path: \(path)")
|
|
108
|
-
|
|
109
|
-
// Extract model parameters
|
|
110
78
|
let maxTokens = call.getInt("maxTokens") ?? 2048
|
|
111
79
|
let topk = call.getInt("topk") ?? 40
|
|
112
80
|
let temperature = call.getFloat("temperature") ?? 0.8
|
|
113
|
-
let
|
|
114
|
-
let
|
|
115
|
-
let tokenizerPath = call.getString("tokenizerPath")
|
|
116
|
-
let specialTokens = call.getArray("specialTokens", String.self) ?? []
|
|
117
|
-
let sequenceLength = call.getInt("sequenceLength") ?? maxTokens
|
|
118
|
-
|
|
119
|
-
guard sequenceLength > 0 else {
|
|
120
|
-
call.reject("sequenceLength must be > 0")
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
guard !isExecuTorchGenerating() else {
|
|
125
|
-
call.reject("Cannot switch model while ExecuTorch is generating. Wait for aiFinished.")
|
|
126
|
-
return
|
|
127
|
-
}
|
|
81
|
+
let randomSeed = call.getInt("randomSeed") ?? 0
|
|
82
|
+
let modelType = call.getString("modelType")
|
|
128
83
|
|
|
129
84
|
modelPath = path
|
|
130
|
-
|
|
131
|
-
|
|
85
|
+
isReady = false
|
|
86
|
+
clearChatSessions()
|
|
87
|
+
#if canImport(LiteRTLM)
|
|
88
|
+
liteRtEngine = nil
|
|
89
|
+
#endif
|
|
90
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
132
91
|
llmInference = nil
|
|
133
92
|
#endif
|
|
134
|
-
execuTorchRunner = nil
|
|
135
|
-
isReady = false
|
|
136
93
|
|
|
137
|
-
|
|
138
|
-
if engine == "apple" || path.lowercased() == "apple intelligence" {
|
|
139
|
-
print("[CapgoLLM] Switching to Apple Intelligence")
|
|
140
|
-
execuTorchRunner = nil
|
|
94
|
+
if path.lowercased() == "apple intelligence" {
|
|
141
95
|
currentModelType = .appleIntelligence
|
|
142
96
|
isReady = true
|
|
143
97
|
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
@@ -145,218 +99,80 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
145
99
|
return
|
|
146
100
|
}
|
|
147
101
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
specialTokens: specialTokens,
|
|
156
|
-
sequenceLength: sequenceLength,
|
|
157
|
-
temperature: temperature
|
|
158
|
-
)
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
#if !targetEnvironment(macCatalyst) && canImport(MediaPipeTasksGenAI)
|
|
163
|
-
currentModelType = .mediaPipe
|
|
164
|
-
Task {
|
|
165
|
-
do {
|
|
166
|
-
let modelURL: URL
|
|
167
|
-
|
|
168
|
-
// Check if it's a path within the app bundle
|
|
169
|
-
if path.hasPrefix("/") {
|
|
170
|
-
modelURL = URL(fileURLWithPath: path)
|
|
171
|
-
print("[CapgoLLM] Using absolute path: \(modelURL.path)")
|
|
172
|
-
} else {
|
|
173
|
-
// Try to find it in the app bundle
|
|
174
|
-
let fileName: String
|
|
175
|
-
let fileExtension: String
|
|
176
|
-
|
|
177
|
-
// If modelType is provided, use it as the extension
|
|
178
|
-
if let providedType = modelType, !providedType.isEmpty {
|
|
179
|
-
// Remove any extension from path and use provided type
|
|
180
|
-
fileName = (path as NSString).deletingPathExtension
|
|
181
|
-
fileExtension = providedType
|
|
182
|
-
print("[CapgoLLM] Using provided model type: \(fileExtension)")
|
|
183
|
-
} else {
|
|
184
|
-
// Extract from path
|
|
185
|
-
fileName = (path as NSString).deletingPathExtension
|
|
186
|
-
fileExtension = (path as NSString).pathExtension
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
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()
|
|
190
109
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
}
|
|
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)
|
|
211
128
|
}
|
|
212
|
-
|
|
213
|
-
// Initialize MediaPipe LLM
|
|
214
|
-
print("[CapgoLLM] Initializing MediaPipe with model at: \(modelURL.path)")
|
|
215
|
-
let options = LlmInference.Options(modelPath: modelURL.path)
|
|
216
|
-
options.maxTokens = maxTokens
|
|
217
|
-
options.maxTopk = topk
|
|
218
|
-
|
|
219
|
-
print("[CapgoLLM] Creating LlmInference instance with maxTokens: \(maxTokens), topk: \(topk), temperature: \(temperature)")
|
|
220
|
-
llmInference = try LlmInference(options: options)
|
|
221
|
-
currentModelType = .mediaPipe
|
|
222
|
-
isReady = true
|
|
223
|
-
|
|
224
|
-
print("[CapgoLLM] Model loaded successfully")
|
|
225
|
-
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
226
|
-
call.resolve()
|
|
227
|
-
} catch {
|
|
228
|
-
llmInference = nil
|
|
229
|
-
isReady = false
|
|
230
|
-
print("[CapgoLLM] Error loading model: \(error.localizedDescription)")
|
|
231
|
-
notifyListeners("readinessChange", data: ["readiness": "Failed to load model: \(error.localizedDescription)"])
|
|
232
|
-
call.reject("Failed to load model: \(error.localizedDescription)")
|
|
233
129
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
private func shouldUseExecuTorch(engine: String, path: String, tokenizerPath: String?) -> Bool {
|
|
241
|
-
if path.lowercased() == "apple intelligence" {
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
if engine == "executorch" {
|
|
245
|
-
return true
|
|
246
|
-
}
|
|
247
|
-
if engine == "apple" || engine == "mediapipe" {
|
|
248
|
-
return false
|
|
249
|
-
}
|
|
250
|
-
if tokenizerPath?.isEmpty == false {
|
|
251
|
-
return true
|
|
252
|
-
}
|
|
253
|
-
return path.lowercased().hasSuffix(".pte")
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private func setExecuTorchModel(
|
|
257
|
-
_ call: CAPPluginCall,
|
|
258
|
-
path: String,
|
|
259
|
-
tokenizerPath: String?,
|
|
260
|
-
modelType: String?,
|
|
261
|
-
specialTokens: [String],
|
|
262
|
-
sequenceLength: Int,
|
|
263
|
-
temperature: Float
|
|
264
|
-
) {
|
|
265
|
-
execuTorchRunner = nil
|
|
266
|
-
|
|
267
|
-
guard sequenceLength > 0 else {
|
|
268
|
-
call.reject("sequenceLength must be > 0")
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
guard DynamicExecuTorchRunner.isAvailable else {
|
|
273
|
-
isReady = false
|
|
274
|
-
notifyListeners("readinessChange", data: ["readiness": execuTorchNotLinkedMessage])
|
|
275
|
-
call.reject(execuTorchNotLinkedMessage)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
guard let tokenizerPath = tokenizerPath, !tokenizerPath.isEmpty else {
|
|
280
|
-
call.reject("tokenizerPath is required for ExecuTorch models")
|
|
281
|
-
return
|
|
282
|
-
}
|
|
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
|
|
283
136
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
isReady = false
|
|
307
|
-
print("[CapgoLLM] Error loading ExecuTorch model: \(error.localizedDescription)")
|
|
308
|
-
notifyListeners("readinessChange", data: ["readiness": "Failed to load ExecuTorch model: \(error.localizedDescription)"])
|
|
309
|
-
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
|
+
}
|
|
310
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
|
|
311
163
|
}
|
|
312
164
|
}
|
|
313
165
|
|
|
314
|
-
private func resolveModelURL(path: String, modelType: String?, defaultExtension: String) throws -> URL {
|
|
315
|
-
if path.hasPrefix("/") {
|
|
316
|
-
return URL(fileURLWithPath: path)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
let fileName: String
|
|
320
|
-
let fileExtension: String
|
|
321
|
-
|
|
322
|
-
if let providedType = modelType, !providedType.isEmpty {
|
|
323
|
-
fileName = (path as NSString).deletingPathExtension
|
|
324
|
-
fileExtension = providedType
|
|
325
|
-
} else {
|
|
326
|
-
fileName = (path as NSString).deletingPathExtension
|
|
327
|
-
let extractedExtension = (path as NSString).pathExtension
|
|
328
|
-
fileExtension = extractedExtension.isEmpty ? defaultExtension : extractedExtension
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fileExtension) else {
|
|
332
|
-
throw NSError(
|
|
333
|
-
domain: "CapgoLLM",
|
|
334
|
-
code: 404,
|
|
335
|
-
userInfo: [NSLocalizedDescriptionKey: "Model file not found in bundle: \(path)"]
|
|
336
|
-
)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return URL(fileURLWithPath: bundlePath)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
166
|
@objc func createChat(_ call: CAPPluginCall) {
|
|
343
|
-
#if targetEnvironment(macCatalyst)
|
|
344
|
-
call.reject(catalystNotSupportedMessage)
|
|
345
|
-
return
|
|
346
|
-
#endif
|
|
347
|
-
|
|
348
|
-
print("[CapgoLLM] Creating chat with model type: \(currentModelType)")
|
|
349
167
|
switch currentModelType {
|
|
350
168
|
case .appleIntelligence:
|
|
351
169
|
#if canImport(FoundationModels)
|
|
352
170
|
if #available(iOS 26.0, *) {
|
|
353
171
|
let instructions = call.getString("instructions")
|
|
354
172
|
let session = LanguageModelSession(instructions: instructions)
|
|
355
|
-
let id = UUID()
|
|
356
|
-
appleChats[id
|
|
357
|
-
call.resolve([
|
|
358
|
-
"id": id.uuidString
|
|
359
|
-
])
|
|
173
|
+
let id = UUID().uuidString
|
|
174
|
+
appleChats[id] = session
|
|
175
|
+
call.resolve(["id": id])
|
|
360
176
|
} else {
|
|
361
177
|
call.reject("Apple Intelligence requires iOS 26.0 or later")
|
|
362
178
|
}
|
|
@@ -364,48 +180,52 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
364
180
|
call.reject("Apple Intelligence is not available on this device")
|
|
365
181
|
#endif
|
|
366
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
|
+
|
|
367
205
|
case .mediaPipe:
|
|
368
|
-
#if
|
|
369
|
-
guard
|
|
370
|
-
print("[CapgoLLM] Error: Model not loaded when creating chat")
|
|
206
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
207
|
+
guard let inference = llmInference else {
|
|
371
208
|
call.reject("Model not loaded")
|
|
372
209
|
return
|
|
373
210
|
}
|
|
374
211
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
"id": id
|
|
380
|
-
|
|
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
|
+
}
|
|
381
220
|
#else
|
|
382
|
-
call.reject("MediaPipe is not available
|
|
221
|
+
call.reject("MediaPipe is not available. Please install via CocoaPods.")
|
|
383
222
|
#endif
|
|
384
|
-
|
|
385
|
-
case .execuTorch:
|
|
386
|
-
guard execuTorchRunner != nil else {
|
|
387
|
-
call.reject("ExecuTorch model not loaded")
|
|
388
|
-
return
|
|
389
|
-
}
|
|
390
|
-
let id = UUID().uuidString
|
|
391
|
-
execuTorchChats[id] = ExecuTorchChatSession()
|
|
392
|
-
print("[CapgoLLM] Created ExecuTorch chat with ID: \(id)")
|
|
393
|
-
call.resolve([
|
|
394
|
-
"id": id
|
|
395
|
-
])
|
|
396
223
|
}
|
|
397
224
|
}
|
|
398
225
|
|
|
399
226
|
@objc func getReadiness(_ call: CAPPluginCall) {
|
|
400
|
-
#if targetEnvironment(macCatalyst)
|
|
401
|
-
call.resolve(["readiness": "unsupported_on_maccatalyst"])
|
|
402
|
-
notifyListeners("readinessChange", data: ["readiness": "unsupported_on_maccatalyst"])
|
|
403
|
-
return
|
|
404
|
-
#endif
|
|
405
|
-
|
|
406
227
|
let readiness = getReadinessStatus()
|
|
407
228
|
call.resolve(["readiness": readiness])
|
|
408
|
-
// Also notify listeners
|
|
409
229
|
notifyListeners("readinessChange", data: ["readiness": readiness])
|
|
410
230
|
}
|
|
411
231
|
|
|
@@ -426,27 +246,18 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
426
246
|
case .unavailable(let other):
|
|
427
247
|
return "Error: \(other)"
|
|
428
248
|
}
|
|
429
|
-
} else {
|
|
430
|
-
return "Apple Intelligence requires iOS 26.0 or later"
|
|
431
249
|
}
|
|
250
|
+
return "Apple Intelligence requires iOS 26.0 or later"
|
|
432
251
|
#else
|
|
433
252
|
return "Apple Intelligence is not available on this device"
|
|
434
253
|
#endif
|
|
435
254
|
|
|
436
|
-
case .mediaPipe:
|
|
437
|
-
return isReady ? "ready" : "not_ready"
|
|
438
|
-
|
|
439
|
-
case .execuTorch:
|
|
255
|
+
case .liteRtLm, .mediaPipe:
|
|
440
256
|
return isReady ? "ready" : "not_ready"
|
|
441
257
|
}
|
|
442
258
|
}
|
|
443
259
|
|
|
444
260
|
@objc func sendMessage(_ call: CAPPluginCall) {
|
|
445
|
-
#if targetEnvironment(macCatalyst)
|
|
446
|
-
call.reject(catalystNotSupportedMessage)
|
|
447
|
-
return
|
|
448
|
-
#endif
|
|
449
|
-
|
|
450
261
|
let chatId = call.getString("chatId", "")
|
|
451
262
|
guard let message = call.getString("message") else {
|
|
452
263
|
call.reject("message not found")
|
|
@@ -457,23 +268,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
457
268
|
case .appleIntelligence:
|
|
458
269
|
#if canImport(FoundationModels)
|
|
459
270
|
if #available(iOS 26.0, *), let model = appleModel as? SystemLanguageModel {
|
|
460
|
-
|
|
461
|
-
case .available:
|
|
462
|
-
break
|
|
463
|
-
case .unavailable(.deviceNotEligible):
|
|
464
|
-
call.reject("error deviceNotEligible error")
|
|
465
|
-
return
|
|
466
|
-
case .unavailable(.appleIntelligenceNotEnabled):
|
|
467
|
-
call.reject("error appleIntelligenceNotEnabled error")
|
|
468
|
-
return
|
|
469
|
-
case .unavailable(.modelNotReady):
|
|
470
|
-
call.reject("error modelNotReady error")
|
|
471
|
-
return
|
|
472
|
-
case .unavailable(let other):
|
|
473
|
-
call.reject("error \(other) error")
|
|
474
|
-
return
|
|
475
|
-
}
|
|
476
|
-
|
|
271
|
+
guard validateAppleAvailability(model: model, call: call) else { return }
|
|
477
272
|
guard let chat = appleChats[chatId] as? LanguageModelSession else {
|
|
478
273
|
call.reject("chat not found")
|
|
479
274
|
return
|
|
@@ -484,47 +279,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
484
279
|
return
|
|
485
280
|
}
|
|
486
281
|
|
|
487
|
-
|
|
488
|
-
do {
|
|
489
|
-
let stream = chat.streamResponse(to: message)
|
|
490
|
-
for try await chunk in stream {
|
|
491
|
-
print("[CapgoLLM] Chunk type: \(type(of: chunk))")
|
|
492
|
-
|
|
493
|
-
// Extract content from Snapshot object using Mirror reflection
|
|
494
|
-
let textChunk: String
|
|
495
|
-
let mirror = Mirror(reflecting: chunk)
|
|
496
|
-
|
|
497
|
-
// Try to find content property
|
|
498
|
-
if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
|
|
499
|
-
let content = contentProperty.value as? String {
|
|
500
|
-
textChunk = content
|
|
501
|
-
print("[CapgoLLM] Extracted content: \(content)")
|
|
502
|
-
} else {
|
|
503
|
-
// Fallback: try rawContent
|
|
504
|
-
if let rawContentProperty = mirror.children.first(where: { $0.label == "rawContent" }),
|
|
505
|
-
let rawContent = rawContentProperty.value as? String {
|
|
506
|
-
textChunk = rawContent
|
|
507
|
-
print("[CapgoLLM] Extracted rawContent: \(rawContent)")
|
|
508
|
-
} else {
|
|
509
|
-
// Final fallback
|
|
510
|
-
textChunk = "\(chunk)"
|
|
511
|
-
print("[CapgoLLM] Fallback to string conversion: \(textChunk)")
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
let data: [String: Any] = [
|
|
515
|
-
"chatId": chatId as String,
|
|
516
|
-
"text": textChunk as String,
|
|
517
|
-
"isChunk": true
|
|
518
|
-
]
|
|
519
|
-
self.notifyListeners("textFromAi", data: data)
|
|
520
|
-
}
|
|
521
|
-
let finishData: [String: Any] = ["chatId": chatId as String]
|
|
522
|
-
self.notifyListeners("aiFinished", data: finishData)
|
|
523
|
-
call.resolve()
|
|
524
|
-
} catch {
|
|
525
|
-
call.reject("Failed to get response: \(error.localizedDescription)")
|
|
526
|
-
}
|
|
527
|
-
}
|
|
282
|
+
streamAppleResponse(chatId: chatId, message: message, chat: chat, call: call)
|
|
528
283
|
} else {
|
|
529
284
|
call.reject("Apple Intelligence requires iOS 26.0 or later")
|
|
530
285
|
}
|
|
@@ -532,114 +287,35 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
532
287
|
call.reject("Apple Intelligence is not available on this device")
|
|
533
288
|
#endif
|
|
534
289
|
|
|
535
|
-
case .
|
|
536
|
-
#if
|
|
537
|
-
guard let
|
|
538
|
-
call.reject("
|
|
290
|
+
case .liteRtLm:
|
|
291
|
+
#if canImport(LiteRTLM)
|
|
292
|
+
guard let conversation = liteRtChats[chatId] else {
|
|
293
|
+
call.reject("chat not found")
|
|
539
294
|
return
|
|
540
295
|
}
|
|
541
296
|
|
|
542
|
-
|
|
543
|
-
do {
|
|
544
|
-
print("[CapgoLLM] Attempting to generate streaming response for message: \(message)")
|
|
545
|
-
|
|
546
|
-
// Use native streaming API
|
|
547
|
-
let resultStream = inference.generateResponseAsync(inputText: message)
|
|
548
|
-
|
|
549
|
-
var fullResponse = ""
|
|
550
|
-
|
|
551
|
-
for try await partialResult in resultStream {
|
|
552
|
-
print("[CapgoLLM] Partial result: \(partialResult)")
|
|
553
|
-
|
|
554
|
-
// Send the chunk
|
|
555
|
-
self.notifyListeners("textFromAi", data: [
|
|
556
|
-
"chatId": chatId,
|
|
557
|
-
"text": partialResult,
|
|
558
|
-
"isChunk": true
|
|
559
|
-
])
|
|
560
|
-
|
|
561
|
-
// Accumulate for history (if needed)
|
|
562
|
-
fullResponse += partialResult
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
print("[CapgoLLM] Streaming complete. Full response: \(fullResponse)")
|
|
566
|
-
|
|
567
|
-
self.notifyListeners("aiFinished", data: ["chatId": chatId])
|
|
568
|
-
call.resolve()
|
|
569
|
-
|
|
570
|
-
} catch {
|
|
571
|
-
print("[CapgoLLM] Error details: \(error)")
|
|
572
|
-
call.reject("Failed to generate response: \(error.localizedDescription)")
|
|
573
|
-
}
|
|
574
|
-
}
|
|
297
|
+
streamLiteRtResponse(chatId: chatId, message: message, conversation: conversation, call: call)
|
|
575
298
|
#else
|
|
576
|
-
call.reject("
|
|
299
|
+
call.reject("LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager.")
|
|
577
300
|
#endif
|
|
578
301
|
|
|
579
|
-
case .
|
|
580
|
-
|
|
581
|
-
|
|
302
|
+
case .mediaPipe:
|
|
303
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
304
|
+
guard llmInference != nil else {
|
|
305
|
+
call.reject("Model not loaded")
|
|
582
306
|
return
|
|
583
307
|
}
|
|
584
|
-
|
|
308
|
+
|
|
309
|
+
guard let session = mediaPipeChats[chatId] else {
|
|
585
310
|
call.reject("chat not found")
|
|
586
311
|
return
|
|
587
312
|
}
|
|
588
|
-
guard beginExecuTorchGeneration() else {
|
|
589
|
-
call.reject("ExecuTorch is already generating. Wait for aiFinished before sending another message.")
|
|
590
|
-
return
|
|
591
|
-
}
|
|
592
313
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
do {
|
|
599
|
-
let prompt = chat.buildPrompt(userMessage: message)
|
|
600
|
-
var fullResponse = ""
|
|
601
|
-
print("[CapgoLLM] Attempting to generate ExecuTorch response for message: \(message)")
|
|
602
|
-
try runner.generate(prompt) { token in
|
|
603
|
-
self.notifyListeners("textFromAi", data: [
|
|
604
|
-
"chatId": chatId,
|
|
605
|
-
"text": token,
|
|
606
|
-
"isChunk": true
|
|
607
|
-
])
|
|
608
|
-
fullResponse += token
|
|
609
|
-
}
|
|
610
|
-
chat.addTurn(userMessage: message, assistantMessage: fullResponse)
|
|
611
|
-
self.notifyListeners("aiFinished", data: ["chatId": chatId])
|
|
612
|
-
call.resolve()
|
|
613
|
-
} catch {
|
|
614
|
-
print("[CapgoLLM] ExecuTorch error details: \(error)")
|
|
615
|
-
call.reject("Failed to generate ExecuTorch response: \(error.localizedDescription)")
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
private func isExecuTorchGenerating() -> Bool {
|
|
622
|
-
execuTorchGenerationLock.lock()
|
|
623
|
-
defer { execuTorchGenerationLock.unlock() }
|
|
624
|
-
return execuTorchIsGenerating
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
private func beginExecuTorchGeneration() -> Bool {
|
|
628
|
-
execuTorchGenerationLock.lock()
|
|
629
|
-
defer { execuTorchGenerationLock.unlock() }
|
|
630
|
-
|
|
631
|
-
if execuTorchIsGenerating {
|
|
632
|
-
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
|
|
633
318
|
}
|
|
634
|
-
|
|
635
|
-
execuTorchIsGenerating = true
|
|
636
|
-
return true
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
private func finishExecuTorchGeneration() {
|
|
640
|
-
execuTorchGenerationLock.lock()
|
|
641
|
-
execuTorchIsGenerating = false
|
|
642
|
-
execuTorchGenerationLock.unlock()
|
|
643
319
|
}
|
|
644
320
|
|
|
645
321
|
@objc func downloadModel(_ call: CAPPluginCall) {
|
|
@@ -655,19 +331,14 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
655
331
|
|
|
656
332
|
let filename = call.getString("filename") ?? url.lastPathComponent
|
|
657
333
|
|
|
658
|
-
// Get documents directory
|
|
659
334
|
guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
660
335
|
call.reject("Could not access documents directory")
|
|
661
336
|
return
|
|
662
337
|
}
|
|
663
338
|
|
|
664
339
|
let destinationURL = documentsPath.appendingPathComponent(filename)
|
|
665
|
-
|
|
666
|
-
// Create download task
|
|
667
340
|
let session = URLSession(configuration: .default, delegate: DownloadDelegate(plugin: self), delegateQueue: nil)
|
|
668
|
-
let downloadTask = session.downloadTask(with: url) {
|
|
669
|
-
guard let self = self else { return }
|
|
670
|
-
|
|
341
|
+
let downloadTask = session.downloadTask(with: url) { tempURL, _, error in
|
|
671
342
|
if let error = error {
|
|
672
343
|
call.reject("Download failed: \(error.localizedDescription)")
|
|
673
344
|
return
|
|
@@ -679,38 +350,31 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
679
350
|
}
|
|
680
351
|
|
|
681
352
|
do {
|
|
682
|
-
// Remove existing file if it exists
|
|
683
353
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
|
684
354
|
try FileManager.default.removeItem(at: destinationURL)
|
|
685
355
|
}
|
|
686
356
|
|
|
687
|
-
// Move downloaded file to documents directory
|
|
688
357
|
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
|
689
358
|
|
|
690
359
|
var result = [
|
|
691
360
|
"path": destinationURL.path
|
|
692
361
|
]
|
|
693
362
|
|
|
694
|
-
// Handle companion URL if provided (for compatibility)
|
|
695
363
|
if let companionUrlString = call.getString("companionUrl"),
|
|
696
364
|
let companionUrl = URL(string: companionUrlString) {
|
|
697
|
-
|
|
698
365
|
let companionFilename = companionUrl.lastPathComponent
|
|
699
366
|
let companionDestination = documentsPath.appendingPathComponent(companionFilename)
|
|
700
367
|
|
|
701
|
-
// Download companion file synchronously for simplicity
|
|
702
368
|
do {
|
|
703
369
|
let companionData = try Data(contentsOf: companionUrl)
|
|
704
370
|
try companionData.write(to: companionDestination)
|
|
705
371
|
result["companionPath"] = companionDestination.path
|
|
706
372
|
} catch {
|
|
707
|
-
// Log error but don't fail the whole download
|
|
708
373
|
print("Failed to download companion file: \(error)")
|
|
709
374
|
}
|
|
710
375
|
}
|
|
711
376
|
|
|
712
377
|
call.resolve(result)
|
|
713
|
-
|
|
714
378
|
} catch {
|
|
715
379
|
call.reject("Failed to save file: \(error.localizedDescription)")
|
|
716
380
|
}
|
|
@@ -722,168 +386,277 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
722
386
|
@objc func getPluginVersion(_ call: CAPPluginCall) {
|
|
723
387
|
call.resolve(["version": self.pluginVersion])
|
|
724
388
|
}
|
|
725
|
-
}
|
|
726
389
|
|
|
727
|
-
private
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
) ->
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
ExecuTorchTokenCallback,
|
|
753
|
-
UnsafeMutablePointer<NSError?>
|
|
754
|
-
) -> Bool
|
|
755
|
-
private typealias ExecuTorchVoidFunction = @convention(c) (AnyObject, Selector) -> Void
|
|
756
|
-
|
|
757
|
-
private final class DynamicExecuTorchRunner {
|
|
758
|
-
private static let runnerInitSelector = NSSelectorFromString("initWithModelPath:tokenizerPath:specialTokens:")
|
|
759
|
-
private static let runnerLoadSelector = NSSelectorFromString("loadWithError:")
|
|
760
|
-
private static let runnerResetSelector = NSSelectorFromString("reset")
|
|
761
|
-
private static let runnerGenerateSelector = NSSelectorFromString("generateWithPrompt:config:tokenCallback:error:")
|
|
762
|
-
private static let configInitSelector = NSSelectorFromString("initWithBlock:")
|
|
763
|
-
|
|
764
|
-
static var isAvailable: Bool {
|
|
765
|
-
guard let runnerClass = findClass("ExecuTorchLLMTextRunner"),
|
|
766
|
-
let configClass = findClass("ExecuTorchLLMConfig") else {
|
|
767
|
-
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
|
|
768
415
|
}
|
|
416
|
+
}
|
|
769
417
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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])
|
|
775
433
|
}
|
|
776
434
|
|
|
777
|
-
private
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
init(
|
|
782
|
-
modelPath: String,
|
|
783
|
-
tokenizerPath: String,
|
|
784
|
-
specialTokens: [String],
|
|
785
|
-
sequenceLength: Int,
|
|
786
|
-
temperature: Double
|
|
787
|
-
) throws {
|
|
788
|
-
guard let runnerClass = Self.findClass("ExecuTorchLLMTextRunner") else {
|
|
789
|
-
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)
|
|
790
438
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
let
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
modelPath as NSString,
|
|
803
|
-
tokenizerPath as NSString,
|
|
804
|
-
specialTokens as NSArray
|
|
805
|
-
).takeRetainedValue()
|
|
806
|
-
self.sequenceLength = sequenceLength
|
|
807
|
-
self.temperature = temperature
|
|
808
|
-
|
|
809
|
-
var loadError: NSError?
|
|
810
|
-
guard load(runner, Self.runnerLoadSelector, &loadError) else {
|
|
811
|
-
throw loadError ?? Self.error("ExecuTorch model failed to load")
|
|
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
|
|
812
450
|
}
|
|
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
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return URL(fileURLWithPath: bundlePath)
|
|
813
462
|
}
|
|
463
|
+
}
|
|
814
464
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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")
|
|
818
480
|
}
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
}
|
|
821
507
|
}
|
|
822
|
-
|
|
823
|
-
try Self.requireInstanceMethod(Self.runnerGenerateSelector, on: runnerClass)
|
|
824
|
-
try Self.requireInstanceMethod(Self.configInitSelector, on: configClass)
|
|
508
|
+
}
|
|
825
509
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
let
|
|
829
|
-
let generate: ExecuTorchGenerateFunction = try Self.objcMessage()
|
|
510
|
+
@available(iOS 26.0, *)
|
|
511
|
+
func extractAppleTextChunk(from chunk: Any) -> String {
|
|
512
|
+
let mirror = Mirror(reflecting: chunk)
|
|
830
513
|
|
|
831
|
-
|
|
514
|
+
if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
|
|
515
|
+
let content = contentProperty.value as? String {
|
|
516
|
+
return content
|
|
517
|
+
}
|
|
832
518
|
|
|
833
|
-
let
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
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
|
|
837
522
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
+
)
|
|
846
538
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
+
}
|
|
857
564
|
}
|
|
565
|
+
|
|
566
|
+
throw lastError ?? NSError(
|
|
567
|
+
domain: "CapgoLLM",
|
|
568
|
+
code: 500,
|
|
569
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to initialize LiteRT-LM on iOS."]
|
|
570
|
+
)
|
|
858
571
|
}
|
|
859
572
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
863
586
|
}
|
|
864
|
-
|
|
587
|
+
|
|
588
|
+
return LiteRTLM.ConversationConfig(
|
|
589
|
+
systemMessage: systemMessage,
|
|
590
|
+
samplerConfig: sampler
|
|
591
|
+
)
|
|
865
592
|
}
|
|
866
593
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
+
}
|
|
870
617
|
}
|
|
871
618
|
}
|
|
619
|
+
}
|
|
620
|
+
#endif
|
|
872
621
|
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
875
630
|
}
|
|
876
631
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}
|
|
880
645
|
|
|
881
|
-
|
|
882
|
-
|
|
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
|
+
}
|
|
883
656
|
}
|
|
884
657
|
}
|
|
658
|
+
#endif
|
|
885
659
|
|
|
886
|
-
// Download delegate class to handle progress updates
|
|
887
660
|
class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
888
661
|
weak var plugin: CAPPlugin?
|
|
889
662
|
|
|
@@ -894,7 +667,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
894
667
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
895
668
|
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
|
|
896
669
|
totalBytesExpectedToWrite: Int64) {
|
|
897
|
-
|
|
898
670
|
if totalBytesExpectedToWrite > 0 {
|
|
899
671
|
let progress = Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100)
|
|
900
672
|
|
|
@@ -908,7 +680,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
908
680
|
|
|
909
681
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
910
682
|
didFinishDownloadingTo location: URL) {
|
|
911
|
-
// Handled in the completion handler
|
|
683
|
+
// Handled in the completion handler.
|
|
912
684
|
}
|
|
913
|
-
|
|
914
685
|
}
|