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