@capgo/capacitor-llm 8.0.19 → 8.1.1
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 +138 -163
- package/android/build.gradle +3 -2
- package/android/src/main/java/ee/forgr/capgo_llm/LLM.java +618 -282
- package/android/src/main/java/ee/forgr/capgo_llm/LLMPlugin.java +38 -63
- package/dist/docs.json +78 -35
- package/dist/esm/definitions.d.ts +34 -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 +398 -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.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#endif
|
|
20
|
+
private let pluginVersion: String = "8.1.1"
|
|
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,24 @@ 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
|
|
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")
|
|
83
|
+
let backend = call.getString("backend")
|
|
128
84
|
|
|
129
85
|
modelPath = path
|
|
130
|
-
|
|
131
|
-
|
|
86
|
+
isReady = false
|
|
87
|
+
clearChatSessions()
|
|
88
|
+
#if canImport(LiteRTLM)
|
|
89
|
+
liteRtEngine = nil
|
|
90
|
+
#endif
|
|
91
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
132
92
|
llmInference = nil
|
|
133
93
|
#endif
|
|
134
|
-
execuTorchRunner = nil
|
|
135
|
-
isReady = false
|
|
136
94
|
|
|
137
|
-
|
|
138
|
-
if engine == "apple" || path.lowercased() == "apple intelligence" {
|
|
139
|
-
print("[CapgoLLM] Switching to Apple Intelligence")
|
|
140
|
-
execuTorchRunner = nil
|
|
95
|
+
if path.lowercased() == "apple intelligence" {
|
|
141
96
|
currentModelType = .appleIntelligence
|
|
142
97
|
isReady = true
|
|
143
98
|
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
@@ -145,218 +100,84 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
145
100
|
return
|
|
146
101
|
}
|
|
147
102
|
|
|
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)")
|
|
103
|
+
let resolvedModelType = resolveModelType(path: path, modelType: modelType)
|
|
104
|
+
switch resolveModelKind(path: path, modelType: modelType) {
|
|
105
|
+
case .appleIntelligence:
|
|
106
|
+
currentModelType = .appleIntelligence
|
|
107
|
+
isReady = true
|
|
108
|
+
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
109
|
+
call.resolve()
|
|
190
110
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
111
|
+
case .liteRtLm:
|
|
112
|
+
#if canImport(LiteRTLM)
|
|
113
|
+
Task {
|
|
114
|
+
do {
|
|
115
|
+
let modelURL = try resolveModelURL(path: path, modelType: modelType)
|
|
116
|
+
let engine = try await initializeLiteRtEngine(
|
|
117
|
+
modelURL: modelURL,
|
|
118
|
+
maxTokens: maxTokens,
|
|
119
|
+
backend: backend
|
|
120
|
+
)
|
|
121
|
+
liteRtEngine = engine
|
|
122
|
+
liteRtTopk = topk
|
|
123
|
+
liteRtTemperature = temperature
|
|
124
|
+
liteRtRandomSeed = randomSeed
|
|
125
|
+
currentModelType = .liteRtLm
|
|
126
|
+
isReady = true
|
|
127
|
+
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
128
|
+
call.resolve()
|
|
129
|
+
} catch {
|
|
130
|
+
let message = "Failed to load model: \(error.localizedDescription)"
|
|
131
|
+
notifyListeners("readinessChange", data: ["readiness": message])
|
|
132
|
+
call.reject(message)
|
|
211
133
|
}
|
|
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
134
|
}
|
|
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
|
-
}
|
|
135
|
+
#else
|
|
136
|
+
let message = resolvedModelType == "litertlm"
|
|
137
|
+
? "LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager."
|
|
138
|
+
: "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."
|
|
139
|
+
call.reject(message)
|
|
140
|
+
#endif
|
|
283
141
|
|
|
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)")
|
|
142
|
+
case .mediaPipe:
|
|
143
|
+
#if canImport(MediaPipeTasksGenAI)
|
|
144
|
+
Task {
|
|
145
|
+
do {
|
|
146
|
+
let modelURL = try resolveModelURL(path: path, modelType: modelType)
|
|
147
|
+
let options = LlmInference.Options(modelPath: modelURL.path)
|
|
148
|
+
options.maxTokens = maxTokens
|
|
149
|
+
options.maxTopk = topk
|
|
150
|
+
|
|
151
|
+
llmInference = try LlmInference(options: options)
|
|
152
|
+
mediaPipeTopk = topk
|
|
153
|
+
mediaPipeTemperature = temperature
|
|
154
|
+
mediaPipeRandomSeed = randomSeed
|
|
155
|
+
currentModelType = .mediaPipe
|
|
156
|
+
isReady = true
|
|
157
|
+
notifyListeners("readinessChange", data: ["readiness": "ready"])
|
|
158
|
+
call.resolve()
|
|
159
|
+
} catch {
|
|
160
|
+
let message = "Failed to load model: \(error.localizedDescription)"
|
|
161
|
+
notifyListeners("readinessChange", data: ["readiness": message])
|
|
162
|
+
call.reject(message)
|
|
163
|
+
}
|
|
310
164
|
}
|
|
165
|
+
#else
|
|
166
|
+
call.reject("MediaPipe is not available. For iOS custom .litertlm models, integrate the plugin through Swift Package Manager to use LiteRT-LM.")
|
|
167
|
+
#endif
|
|
311
168
|
}
|
|
312
169
|
}
|
|
313
170
|
|
|
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
171
|
@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
172
|
switch currentModelType {
|
|
350
173
|
case .appleIntelligence:
|
|
351
174
|
#if canImport(FoundationModels)
|
|
352
175
|
if #available(iOS 26.0, *) {
|
|
353
176
|
let instructions = call.getString("instructions")
|
|
354
177
|
let session = LanguageModelSession(instructions: instructions)
|
|
355
|
-
let id = UUID()
|
|
356
|
-
appleChats[id
|
|
357
|
-
call.resolve([
|
|
358
|
-
"id": id.uuidString
|
|
359
|
-
])
|
|
178
|
+
let id = UUID().uuidString
|
|
179
|
+
appleChats[id] = session
|
|
180
|
+
call.resolve(["id": id])
|
|
360
181
|
} else {
|
|
361
182
|
call.reject("Apple Intelligence requires iOS 26.0 or later")
|
|
362
183
|
}
|
|
@@ -364,48 +185,52 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
364
185
|
call.reject("Apple Intelligence is not available on this device")
|
|
365
186
|
#endif
|
|
366
187
|
|
|
188
|
+
case .liteRtLm:
|
|
189
|
+
#if canImport(LiteRTLM)
|
|
190
|
+
Task {
|
|
191
|
+
do {
|
|
192
|
+
guard let engine = liteRtEngine else {
|
|
193
|
+
call.reject("LiteRT-LM engine not loaded")
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let conversationConfig = try makeLiteRtConversationConfig(instructions: call.getString("instructions"))
|
|
198
|
+
let conversation = try await engine.createConversation(with: conversationConfig)
|
|
199
|
+
let id = UUID().uuidString
|
|
200
|
+
liteRtChats[id] = conversation
|
|
201
|
+
call.resolve(["id": id])
|
|
202
|
+
} catch {
|
|
203
|
+
call.reject("Failed to create LiteRT-LM chat: \(error.localizedDescription)")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
#else
|
|
207
|
+
call.reject("LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager.")
|
|
208
|
+
#endif
|
|
209
|
+
|
|
367
210
|
case .mediaPipe:
|
|
368
|
-
#if
|
|
369
|
-
guard
|
|
370
|
-
print("[CapgoLLM] Error: Model not loaded when creating chat")
|
|
211
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
212
|
+
guard let inference = llmInference else {
|
|
371
213
|
call.reject("Model not loaded")
|
|
372
214
|
return
|
|
373
215
|
}
|
|
374
216
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
"id": id
|
|
380
|
-
|
|
217
|
+
do {
|
|
218
|
+
let session = try LlmInference.Session(llmInference: inference, options: makeMediaPipeSessionOptions())
|
|
219
|
+
let id = UUID().uuidString
|
|
220
|
+
mediaPipeChats[id] = session
|
|
221
|
+
call.resolve(["id": id])
|
|
222
|
+
} catch {
|
|
223
|
+
call.reject("Failed to create chat: \(error.localizedDescription)")
|
|
224
|
+
}
|
|
381
225
|
#else
|
|
382
|
-
call.reject("MediaPipe is not available
|
|
226
|
+
call.reject("MediaPipe is not available. Please install via CocoaPods.")
|
|
383
227
|
#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
228
|
}
|
|
397
229
|
}
|
|
398
230
|
|
|
399
231
|
@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
232
|
let readiness = getReadinessStatus()
|
|
407
233
|
call.resolve(["readiness": readiness])
|
|
408
|
-
// Also notify listeners
|
|
409
234
|
notifyListeners("readinessChange", data: ["readiness": readiness])
|
|
410
235
|
}
|
|
411
236
|
|
|
@@ -426,27 +251,18 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
426
251
|
case .unavailable(let other):
|
|
427
252
|
return "Error: \(other)"
|
|
428
253
|
}
|
|
429
|
-
} else {
|
|
430
|
-
return "Apple Intelligence requires iOS 26.0 or later"
|
|
431
254
|
}
|
|
255
|
+
return "Apple Intelligence requires iOS 26.0 or later"
|
|
432
256
|
#else
|
|
433
257
|
return "Apple Intelligence is not available on this device"
|
|
434
258
|
#endif
|
|
435
259
|
|
|
436
|
-
case .mediaPipe:
|
|
437
|
-
return isReady ? "ready" : "not_ready"
|
|
438
|
-
|
|
439
|
-
case .execuTorch:
|
|
260
|
+
case .liteRtLm, .mediaPipe:
|
|
440
261
|
return isReady ? "ready" : "not_ready"
|
|
441
262
|
}
|
|
442
263
|
}
|
|
443
264
|
|
|
444
265
|
@objc func sendMessage(_ call: CAPPluginCall) {
|
|
445
|
-
#if targetEnvironment(macCatalyst)
|
|
446
|
-
call.reject(catalystNotSupportedMessage)
|
|
447
|
-
return
|
|
448
|
-
#endif
|
|
449
|
-
|
|
450
266
|
let chatId = call.getString("chatId", "")
|
|
451
267
|
guard let message = call.getString("message") else {
|
|
452
268
|
call.reject("message not found")
|
|
@@ -457,23 +273,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
457
273
|
case .appleIntelligence:
|
|
458
274
|
#if canImport(FoundationModels)
|
|
459
275
|
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
|
-
|
|
276
|
+
guard validateAppleAvailability(model: model, call: call) else { return }
|
|
477
277
|
guard let chat = appleChats[chatId] as? LanguageModelSession else {
|
|
478
278
|
call.reject("chat not found")
|
|
479
279
|
return
|
|
@@ -484,47 +284,7 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
484
284
|
return
|
|
485
285
|
}
|
|
486
286
|
|
|
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
|
-
}
|
|
287
|
+
streamAppleResponse(chatId: chatId, message: message, chat: chat, call: call)
|
|
528
288
|
} else {
|
|
529
289
|
call.reject("Apple Intelligence requires iOS 26.0 or later")
|
|
530
290
|
}
|
|
@@ -532,114 +292,35 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
532
292
|
call.reject("Apple Intelligence is not available on this device")
|
|
533
293
|
#endif
|
|
534
294
|
|
|
535
|
-
case .
|
|
536
|
-
#if
|
|
537
|
-
guard let
|
|
538
|
-
call.reject("
|
|
295
|
+
case .liteRtLm:
|
|
296
|
+
#if canImport(LiteRTLM)
|
|
297
|
+
guard let conversation = liteRtChats[chatId] else {
|
|
298
|
+
call.reject("chat not found")
|
|
539
299
|
return
|
|
540
300
|
}
|
|
541
301
|
|
|
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
|
-
}
|
|
302
|
+
streamLiteRtResponse(chatId: chatId, message: message, conversation: conversation, call: call)
|
|
575
303
|
#else
|
|
576
|
-
call.reject("
|
|
304
|
+
call.reject("LiteRT-LM on iOS is available only when this plugin is integrated through Swift Package Manager.")
|
|
577
305
|
#endif
|
|
578
306
|
|
|
579
|
-
case .
|
|
580
|
-
|
|
581
|
-
|
|
307
|
+
case .mediaPipe:
|
|
308
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
309
|
+
guard llmInference != nil else {
|
|
310
|
+
call.reject("Model not loaded")
|
|
582
311
|
return
|
|
583
312
|
}
|
|
584
|
-
|
|
313
|
+
|
|
314
|
+
guard let session = mediaPipeChats[chatId] else {
|
|
585
315
|
call.reject("chat not found")
|
|
586
316
|
return
|
|
587
317
|
}
|
|
588
|
-
guard beginExecuTorchGeneration() else {
|
|
589
|
-
call.reject("ExecuTorch is already generating. Wait for aiFinished before sending another message.")
|
|
590
|
-
return
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
Task {
|
|
594
|
-
defer {
|
|
595
|
-
self.finishExecuTorchGeneration()
|
|
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
318
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
private func beginExecuTorchGeneration() -> Bool {
|
|
628
|
-
execuTorchGenerationLock.lock()
|
|
629
|
-
defer { execuTorchGenerationLock.unlock() }
|
|
630
|
-
|
|
631
|
-
if execuTorchIsGenerating {
|
|
632
|
-
return false
|
|
319
|
+
streamMediaPipeResponse(chatId: chatId, message: message, session: session, call: call)
|
|
320
|
+
#else
|
|
321
|
+
call.reject("MediaPipe is not available. Please install via CocoaPods.")
|
|
322
|
+
#endif
|
|
633
323
|
}
|
|
634
|
-
|
|
635
|
-
execuTorchIsGenerating = true
|
|
636
|
-
return true
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
private func finishExecuTorchGeneration() {
|
|
640
|
-
execuTorchGenerationLock.lock()
|
|
641
|
-
execuTorchIsGenerating = false
|
|
642
|
-
execuTorchGenerationLock.unlock()
|
|
643
324
|
}
|
|
644
325
|
|
|
645
326
|
@objc func downloadModel(_ call: CAPPluginCall) {
|
|
@@ -655,19 +336,14 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
655
336
|
|
|
656
337
|
let filename = call.getString("filename") ?? url.lastPathComponent
|
|
657
338
|
|
|
658
|
-
// Get documents directory
|
|
659
339
|
guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
660
340
|
call.reject("Could not access documents directory")
|
|
661
341
|
return
|
|
662
342
|
}
|
|
663
343
|
|
|
664
344
|
let destinationURL = documentsPath.appendingPathComponent(filename)
|
|
665
|
-
|
|
666
|
-
// Create download task
|
|
667
345
|
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
|
-
|
|
346
|
+
let downloadTask = session.downloadTask(with: url) { tempURL, _, error in
|
|
671
347
|
if let error = error {
|
|
672
348
|
call.reject("Download failed: \(error.localizedDescription)")
|
|
673
349
|
return
|
|
@@ -679,38 +355,31 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
679
355
|
}
|
|
680
356
|
|
|
681
357
|
do {
|
|
682
|
-
// Remove existing file if it exists
|
|
683
358
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
|
684
359
|
try FileManager.default.removeItem(at: destinationURL)
|
|
685
360
|
}
|
|
686
361
|
|
|
687
|
-
// Move downloaded file to documents directory
|
|
688
362
|
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
|
689
363
|
|
|
690
364
|
var result = [
|
|
691
365
|
"path": destinationURL.path
|
|
692
366
|
]
|
|
693
367
|
|
|
694
|
-
// Handle companion URL if provided (for compatibility)
|
|
695
368
|
if let companionUrlString = call.getString("companionUrl"),
|
|
696
369
|
let companionUrl = URL(string: companionUrlString) {
|
|
697
|
-
|
|
698
370
|
let companionFilename = companionUrl.lastPathComponent
|
|
699
371
|
let companionDestination = documentsPath.appendingPathComponent(companionFilename)
|
|
700
372
|
|
|
701
|
-
// Download companion file synchronously for simplicity
|
|
702
373
|
do {
|
|
703
374
|
let companionData = try Data(contentsOf: companionUrl)
|
|
704
375
|
try companionData.write(to: companionDestination)
|
|
705
376
|
result["companionPath"] = companionDestination.path
|
|
706
377
|
} catch {
|
|
707
|
-
// Log error but don't fail the whole download
|
|
708
378
|
print("Failed to download companion file: \(error)")
|
|
709
379
|
}
|
|
710
380
|
}
|
|
711
381
|
|
|
712
382
|
call.resolve(result)
|
|
713
|
-
|
|
714
383
|
} catch {
|
|
715
384
|
call.reject("Failed to save file: \(error.localizedDescription)")
|
|
716
385
|
}
|
|
@@ -722,168 +391,286 @@ public class LLMPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
722
391
|
@objc func getPluginVersion(_ call: CAPPluginCall) {
|
|
723
392
|
call.resolve(["version": self.pluginVersion])
|
|
724
393
|
}
|
|
725
|
-
}
|
|
726
394
|
|
|
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
|
|
395
|
+
private func clearChatSessions() {
|
|
396
|
+
#if canImport(FoundationModels)
|
|
397
|
+
appleChats.removeAll()
|
|
398
|
+
#endif
|
|
399
|
+
#if canImport(LiteRTLM)
|
|
400
|
+
liteRtChats.removeAll()
|
|
401
|
+
#endif
|
|
402
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
403
|
+
mediaPipeChats.removeAll()
|
|
404
|
+
#endif
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private func resolveModelKind(path: String, modelType: String?) -> ModelType {
|
|
408
|
+
let resolvedModelType = resolveModelType(path: path, modelType: modelType)
|
|
409
|
+
switch resolvedModelType {
|
|
410
|
+
case "litertlm":
|
|
411
|
+
return .liteRtLm
|
|
412
|
+
case "task":
|
|
413
|
+
return .mediaPipe
|
|
414
|
+
default:
|
|
415
|
+
#if canImport(LiteRTLM)
|
|
416
|
+
return .liteRtLm
|
|
417
|
+
#else
|
|
418
|
+
return .mediaPipe
|
|
419
|
+
#endif
|
|
768
420
|
}
|
|
421
|
+
}
|
|
769
422
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
423
|
+
private func resolveModelType(path: String, modelType: String?) -> String {
|
|
424
|
+
if let modelType, !modelType.isEmpty {
|
|
425
|
+
return stripURLSuffix(modelType).lowercased()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let normalizedPath = stripURLSuffix(path)
|
|
429
|
+
let pathExtension = (normalizedPath as NSString).pathExtension
|
|
430
|
+
return pathExtension.lowercased()
|
|
775
431
|
}
|
|
776
432
|
|
|
777
|
-
private
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
) throws {
|
|
788
|
-
guard let runnerClass = Self.findClass("ExecuTorchLLMTextRunner") else {
|
|
789
|
-
throw Self.error("ExecuTorchLLMTextRunner was not found. Link ExecuTorch in the app target.")
|
|
433
|
+
private func stripURLSuffix(_ value: String) -> String {
|
|
434
|
+
let queryIndex = value.firstIndex(of: "?")
|
|
435
|
+
let fragmentIndex = value.firstIndex(of: "#")
|
|
436
|
+
let cutoff = [queryIndex, fragmentIndex].compactMap { $0 }.min() ?? value.endIndex
|
|
437
|
+
return String(value[..<cutoff])
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private func resolveModelURL(path: String, modelType: String?) throws -> URL {
|
|
441
|
+
if path.hasPrefix("/") {
|
|
442
|
+
return URL(fileURLWithPath: path)
|
|
790
443
|
}
|
|
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")
|
|
444
|
+
|
|
445
|
+
let fileName: String
|
|
446
|
+
let fileExtension: String
|
|
447
|
+
|
|
448
|
+
if let modelType, !modelType.isEmpty {
|
|
449
|
+
fileName = (path as NSString).deletingPathExtension
|
|
450
|
+
fileExtension = stripURLSuffix(modelType)
|
|
451
|
+
} else {
|
|
452
|
+
let normalizedPath = stripURLSuffix(path)
|
|
453
|
+
fileName = (normalizedPath as NSString).deletingPathExtension
|
|
454
|
+
fileExtension = (normalizedPath as NSString).pathExtension
|
|
812
455
|
}
|
|
456
|
+
|
|
457
|
+
let fallbackExtension = fileExtension.isEmpty ? "bin" : fileExtension
|
|
458
|
+
guard let bundlePath = Bundle.main.path(forResource: fileName, ofType: fallbackExtension) else {
|
|
459
|
+
throw NSError(
|
|
460
|
+
domain: "CapgoLLM",
|
|
461
|
+
code: 404,
|
|
462
|
+
userInfo: [NSLocalizedDescriptionKey: "Model file not found in bundle: \(path)"]
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return URL(fileURLWithPath: bundlePath)
|
|
813
467
|
}
|
|
468
|
+
}
|
|
814
469
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
470
|
+
#if canImport(FoundationModels)
|
|
471
|
+
private extension LLMPlugin {
|
|
472
|
+
@available(iOS 26.0, *)
|
|
473
|
+
func validateAppleAvailability(model: SystemLanguageModel, call: CAPPluginCall) -> Bool {
|
|
474
|
+
switch model.availability {
|
|
475
|
+
case .available:
|
|
476
|
+
return true
|
|
477
|
+
case .unavailable(.deviceNotEligible):
|
|
478
|
+
call.reject("error deviceNotEligible error")
|
|
479
|
+
case .unavailable(.appleIntelligenceNotEnabled):
|
|
480
|
+
call.reject("error appleIntelligenceNotEnabled error")
|
|
481
|
+
case .unavailable(.modelNotReady):
|
|
482
|
+
call.reject("error modelNotReady error")
|
|
483
|
+
case .unavailable(let other):
|
|
484
|
+
call.reject("error \(other) error")
|
|
818
485
|
}
|
|
819
|
-
|
|
820
|
-
|
|
486
|
+
|
|
487
|
+
return false
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@available(iOS 26.0, *)
|
|
491
|
+
func streamAppleResponse(chatId: String, message: String, chat: LanguageModelSession, call: CAPPluginCall) {
|
|
492
|
+
Task {
|
|
493
|
+
do {
|
|
494
|
+
let stream = chat.streamResponse(to: message)
|
|
495
|
+
for try await chunk in stream {
|
|
496
|
+
let textChunk = extractAppleTextChunk(from: chunk)
|
|
497
|
+
notifyListeners("textFromAi", data: [
|
|
498
|
+
"chatId": chatId,
|
|
499
|
+
"text": textChunk,
|
|
500
|
+
"isChunk": true
|
|
501
|
+
])
|
|
502
|
+
}
|
|
503
|
+
notifyListeners("aiFinished", data: ["chatId": chatId])
|
|
504
|
+
call.resolve()
|
|
505
|
+
} catch {
|
|
506
|
+
notifyListeners("generationError", data: [
|
|
507
|
+
"chatId": chatId,
|
|
508
|
+
"error": error.localizedDescription
|
|
509
|
+
])
|
|
510
|
+
call.reject("Failed to get response: \(error.localizedDescription)")
|
|
511
|
+
}
|
|
821
512
|
}
|
|
822
|
-
|
|
823
|
-
try Self.requireInstanceMethod(Self.runnerGenerateSelector, on: runnerClass)
|
|
824
|
-
try Self.requireInstanceMethod(Self.configInitSelector, on: configClass)
|
|
513
|
+
}
|
|
825
514
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
let
|
|
829
|
-
let generate: ExecuTorchGenerateFunction = try Self.objcMessage()
|
|
515
|
+
@available(iOS 26.0, *)
|
|
516
|
+
func extractAppleTextChunk(from chunk: Any) -> String {
|
|
517
|
+
let mirror = Mirror(reflecting: chunk)
|
|
830
518
|
|
|
831
|
-
|
|
519
|
+
if let contentProperty = mirror.children.first(where: { $0.label == "content" }),
|
|
520
|
+
let content = contentProperty.value as? String {
|
|
521
|
+
return content
|
|
522
|
+
}
|
|
832
523
|
|
|
833
|
-
let
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
config.setValue(sequenceLength, forKey: "sequenceLength")
|
|
524
|
+
if let rawContentProperty = mirror.children.first(where: { $0.label == "rawContent" }),
|
|
525
|
+
let rawContent = rawContentProperty.value as? String {
|
|
526
|
+
return rawContent
|
|
837
527
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
528
|
+
|
|
529
|
+
return "\(chunk)"
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
#endif
|
|
533
|
+
|
|
534
|
+
#if canImport(LiteRTLM)
|
|
535
|
+
private extension LLMPlugin {
|
|
536
|
+
func initializeLiteRtEngine(
|
|
537
|
+
modelURL: URL,
|
|
538
|
+
maxTokens: Int,
|
|
539
|
+
backend: String?
|
|
540
|
+
) async throws -> LiteRTLM.Engine {
|
|
541
|
+
guard let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
|
542
|
+
throw NSError(
|
|
543
|
+
domain: "CapgoLLM",
|
|
544
|
+
code: 500,
|
|
545
|
+
userInfo: [NSLocalizedDescriptionKey: "Could not access the iOS caches directory for LiteRT-LM."]
|
|
546
|
+
)
|
|
846
547
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
throw generateError ?? Self.error("ExecuTorch generation failed")
|
|
548
|
+
|
|
549
|
+
let cacheDir = cacheDirectory.path
|
|
550
|
+
let makeConfig = { (selectedBackend: LiteRTLM.Backend) throws -> LiteRTLM.EngineConfig in
|
|
551
|
+
try LiteRTLM.EngineConfig(
|
|
552
|
+
modelPath: modelURL.path,
|
|
553
|
+
backend: selectedBackend,
|
|
554
|
+
maxNumTokens: maxTokens,
|
|
555
|
+
cacheDir: cacheDir
|
|
556
|
+
)
|
|
857
557
|
}
|
|
858
|
-
}
|
|
859
558
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
559
|
+
let configs: [LiteRTLM.EngineConfig]
|
|
560
|
+
switch backend?.lowercased() {
|
|
561
|
+
case "cpu":
|
|
562
|
+
configs = [try makeConfig(.cpu())]
|
|
563
|
+
case "gpu":
|
|
564
|
+
configs = [try makeConfig(.gpu), try makeConfig(.cpu())]
|
|
565
|
+
default:
|
|
566
|
+
configs = [try makeConfig(.cpu()), try makeConfig(.gpu)]
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
var lastError: Error?
|
|
570
|
+
for config in configs {
|
|
571
|
+
let engine = LiteRTLM.Engine(engineConfig: config)
|
|
572
|
+
do {
|
|
573
|
+
try await engine.initialize()
|
|
574
|
+
return engine
|
|
575
|
+
} catch {
|
|
576
|
+
lastError = error
|
|
577
|
+
}
|
|
863
578
|
}
|
|
864
|
-
|
|
579
|
+
|
|
580
|
+
throw lastError ?? NSError(
|
|
581
|
+
domain: "CapgoLLM",
|
|
582
|
+
code: 500,
|
|
583
|
+
userInfo: [NSLocalizedDescriptionKey: "Failed to initialize LiteRT-LM on iOS."]
|
|
584
|
+
)
|
|
865
585
|
}
|
|
866
586
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
587
|
+
func makeLiteRtConversationConfig(instructions: String?) throws -> LiteRTLM.ConversationConfig {
|
|
588
|
+
let sampler = try LiteRTLM.SamplerConfig(
|
|
589
|
+
topK: liteRtTopk,
|
|
590
|
+
topP: liteRtTopP,
|
|
591
|
+
temperature: liteRtTemperature,
|
|
592
|
+
seed: liteRtRandomSeed
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
let systemMessage: LiteRTLM.Message?
|
|
596
|
+
if let instructions, !instructions.isEmpty {
|
|
597
|
+
systemMessage = LiteRTLM.Message(instructions, role: .system)
|
|
598
|
+
} else {
|
|
599
|
+
systemMessage = nil
|
|
870
600
|
}
|
|
601
|
+
|
|
602
|
+
return LiteRTLM.ConversationConfig(
|
|
603
|
+
systemMessage: systemMessage,
|
|
604
|
+
samplerConfig: sampler
|
|
605
|
+
)
|
|
871
606
|
}
|
|
872
607
|
|
|
873
|
-
|
|
874
|
-
|
|
608
|
+
func streamLiteRtResponse(chatId: String, message: String, conversation: LiteRTLM.Conversation, call: CAPPluginCall) {
|
|
609
|
+
Task {
|
|
610
|
+
do {
|
|
611
|
+
for try await chunk in conversation.sendMessageStream(LiteRTLM.Message(message)) {
|
|
612
|
+
let textChunk = chunk.toString
|
|
613
|
+
if !textChunk.isEmpty {
|
|
614
|
+
notifyListeners("textFromAi", data: [
|
|
615
|
+
"chatId": chatId,
|
|
616
|
+
"text": textChunk,
|
|
617
|
+
"isChunk": true
|
|
618
|
+
])
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
notifyListeners("aiFinished", data: ["chatId": chatId])
|
|
623
|
+
call.resolve()
|
|
624
|
+
} catch {
|
|
625
|
+
notifyListeners("generationError", data: [
|
|
626
|
+
"chatId": chatId,
|
|
627
|
+
"error": error.localizedDescription
|
|
628
|
+
])
|
|
629
|
+
call.reject("Failed to generate response: \(error.localizedDescription)")
|
|
630
|
+
}
|
|
631
|
+
}
|
|
875
632
|
}
|
|
633
|
+
}
|
|
634
|
+
#endif
|
|
876
635
|
|
|
877
|
-
|
|
878
|
-
|
|
636
|
+
#if COCOAPODS || canImport(MediaPipeTasksGenAI)
|
|
637
|
+
private extension LLMPlugin {
|
|
638
|
+
func makeMediaPipeSessionOptions() -> LlmInference.Session.Options {
|
|
639
|
+
let options = LlmInference.Session.Options()
|
|
640
|
+
options.topk = mediaPipeTopk
|
|
641
|
+
options.temperature = mediaPipeTemperature
|
|
642
|
+
options.randomSeed = mediaPipeRandomSeed
|
|
643
|
+
return options
|
|
879
644
|
}
|
|
880
645
|
|
|
881
|
-
|
|
882
|
-
|
|
646
|
+
func streamMediaPipeResponse(chatId: String, message: String, session: LlmInference.Session, call: CAPPluginCall) {
|
|
647
|
+
Task {
|
|
648
|
+
do {
|
|
649
|
+
try session.addQueryChunk(inputText: message)
|
|
650
|
+
let resultStream = session.generateResponseAsync()
|
|
651
|
+
|
|
652
|
+
for try await partialResult in resultStream {
|
|
653
|
+
notifyListeners("textFromAi", data: [
|
|
654
|
+
"chatId": chatId,
|
|
655
|
+
"text": partialResult,
|
|
656
|
+
"isChunk": true
|
|
657
|
+
])
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
notifyListeners("aiFinished", data: ["chatId": chatId])
|
|
661
|
+
call.resolve()
|
|
662
|
+
} catch {
|
|
663
|
+
notifyListeners("generationError", data: [
|
|
664
|
+
"chatId": chatId,
|
|
665
|
+
"error": error.localizedDescription
|
|
666
|
+
])
|
|
667
|
+
call.reject("Failed to generate response: \(error.localizedDescription)")
|
|
668
|
+
}
|
|
669
|
+
}
|
|
883
670
|
}
|
|
884
671
|
}
|
|
672
|
+
#endif
|
|
885
673
|
|
|
886
|
-
// Download delegate class to handle progress updates
|
|
887
674
|
class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
888
675
|
weak var plugin: CAPPlugin?
|
|
889
676
|
|
|
@@ -894,7 +681,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
894
681
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
895
682
|
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
|
|
896
683
|
totalBytesExpectedToWrite: Int64) {
|
|
897
|
-
|
|
898
684
|
if totalBytesExpectedToWrite > 0 {
|
|
899
685
|
let progress = Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100)
|
|
900
686
|
|
|
@@ -908,7 +694,6 @@ class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
908
694
|
|
|
909
695
|
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
910
696
|
didFinishDownloadingTo location: URL) {
|
|
911
|
-
// Handled in the completion handler
|
|
697
|
+
// Handled in the completion handler.
|
|
912
698
|
}
|
|
913
|
-
|
|
914
699
|
}
|