capacitor-apple-intelligence 1.0.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.
@@ -0,0 +1,594 @@
1
+ import Foundation
2
+
3
+ #if canImport(FoundationModels)
4
+ import FoundationModels
5
+ #endif
6
+
7
+ // MARK: - Error Types
8
+
9
+ /// Error codes for Apple Intelligence plugin
10
+ public enum AppleIntelligenceErrorCode: String {
11
+ case invalidJson = "INVALID_JSON"
12
+ case schemaMismatch = "SCHEMA_MISMATCH"
13
+ case unavailable = "UNAVAILABLE"
14
+ case nativeError = "NATIVE_ERROR"
15
+ }
16
+
17
+ /// Custom error type for Apple Intelligence operations
18
+ public struct AppleIntelligenceError: Error {
19
+ public let code: AppleIntelligenceErrorCode
20
+ public let message: String
21
+
22
+ public init(code: AppleIntelligenceErrorCode, message: String) {
23
+ self.code = code
24
+ self.message = message
25
+ }
26
+
27
+ public var asDictionary: [String: Any] {
28
+ return [
29
+ "code": code.rawValue,
30
+ "message": message
31
+ ]
32
+ }
33
+ }
34
+
35
+ // MARK: - Message Types
36
+
37
+ /// Role for a message in the conversation
38
+ public enum MessageRole: String {
39
+ case system
40
+ case user
41
+ }
42
+
43
+ /// A message in the conversation
44
+ public struct Message {
45
+ public let role: MessageRole
46
+ public let content: String
47
+
48
+ public init(role: MessageRole, content: String) {
49
+ self.role = role
50
+ self.content = content
51
+ }
52
+ }
53
+
54
+ // MARK: - Main Implementation
55
+
56
+ /// Apple Intelligence implementation class
57
+ /// Handles on-device LLM generation with JSON schema validation
58
+ @objc public class AppleIntelligence: NSObject {
59
+
60
+ // MARK: - Constants
61
+
62
+ private let maxRetries = 1
63
+
64
+ // MARK: - JSON Schema Validation
65
+
66
+ /// Validate JSON data against a JSON schema
67
+ private func validateAgainstSchema(_ json: Any, schema: [String: Any]) -> (valid: Bool, error: String?) {
68
+ guard let schemaType = schema["type"] as? String else {
69
+ return (false, "Schema missing 'type' property")
70
+ }
71
+
72
+ switch schemaType {
73
+ case "object":
74
+ return validateObject(json, schema: schema)
75
+ case "array":
76
+ return validateArray(json, schema: schema)
77
+ case "string":
78
+ return (json is String, json is String ? nil : "Expected string, got \(type(of: json))")
79
+ case "number", "integer":
80
+ return (json is NSNumber && !(json is Bool),
81
+ (json is NSNumber && !(json is Bool)) ? nil : "Expected number, got \(type(of: json))")
82
+ case "boolean":
83
+ return (json is Bool, json is Bool ? nil : "Expected boolean, got \(type(of: json))")
84
+ case "null":
85
+ return (json is NSNull, json is NSNull ? nil : "Expected null, got \(type(of: json))")
86
+ default:
87
+ return (false, "Unknown schema type: \(schemaType)")
88
+ }
89
+ }
90
+
91
+ /// Validate an object against a schema
92
+ private func validateObject(_ json: Any, schema: [String: Any]) -> (valid: Bool, error: String?) {
93
+ guard let jsonObject = json as? [String: Any] else {
94
+ return (false, "Expected object, got \(type(of: json))")
95
+ }
96
+
97
+ // Check required properties
98
+ if let required = schema["required"] as? [String] {
99
+ for requiredProp in required {
100
+ if jsonObject[requiredProp] == nil {
101
+ return (false, "Missing required property: '\(requiredProp)'")
102
+ }
103
+ }
104
+ }
105
+
106
+ // Validate properties against their schemas
107
+ if let properties = schema["properties"] as? [String: Any] {
108
+ for (key, value) in jsonObject {
109
+ if let propSchema = properties[key] as? [String: Any] {
110
+ let result = validateAgainstSchema(value, schema: propSchema)
111
+ if !result.valid {
112
+ return (false, "Property '\(key)': \(result.error ?? "validation failed")")
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return (true, nil)
119
+ }
120
+
121
+ /// Validate an array against a schema
122
+ private func validateArray(_ json: Any, schema: [String: Any]) -> (valid: Bool, error: String?) {
123
+ guard let jsonArray = json as? [Any] else {
124
+ return (false, "Expected array, got \(type(of: json))")
125
+ }
126
+
127
+ // Validate items against item schema
128
+ if let itemSchema = schema["items"] as? [String: Any] {
129
+ for (index, item) in jsonArray.enumerated() {
130
+ let result = validateAgainstSchema(item, schema: itemSchema)
131
+ if !result.valid {
132
+ return (false, "Item at index \(index): \(result.error ?? "validation failed")")
133
+ }
134
+ }
135
+ }
136
+
137
+ // Validate minItems
138
+ if let minItems = schema["minItems"] as? Int {
139
+ if jsonArray.count < minItems {
140
+ return (false, "Array has \(jsonArray.count) items, minimum is \(minItems)")
141
+ }
142
+ }
143
+
144
+ // Validate maxItems
145
+ if let maxItems = schema["maxItems"] as? Int {
146
+ if jsonArray.count > maxItems {
147
+ return (false, "Array has \(jsonArray.count) items, maximum is \(maxItems)")
148
+ }
149
+ }
150
+
151
+ return (true, nil)
152
+ }
153
+
154
+ // MARK: - Prompt Building
155
+
156
+ /// Build the system prompt with JSON schema instructions
157
+ private func buildSystemPrompt(userSystemPrompt: String?, schema: [String: Any]) -> String {
158
+ let schemaJson: String
159
+ do {
160
+ let schemaData = try JSONSerialization.data(withJSONObject: schema, options: [.prettyPrinted, .sortedKeys])
161
+ schemaJson = String(data: schemaData, encoding: .utf8) ?? "{}"
162
+ } catch {
163
+ schemaJson = "{}"
164
+ }
165
+
166
+ var prompt = """
167
+ You are a JSON generator. Your response must be ONLY valid JSON that matches the provided schema.
168
+
169
+ SCHEMA:
170
+ \(schemaJson)
171
+
172
+ RULES:
173
+ 1. Return ONLY the JSON object or array - nothing else
174
+ 2. Do NOT wrap the response in markdown code blocks (no ```)
175
+ 3. Do NOT include any comments
176
+ 4. Do NOT include any explanations before or after the JSON
177
+ 5. The JSON must be valid and parseable
178
+ 6. All required properties must be present
179
+ 7. Property types must match the schema exactly
180
+
181
+ """
182
+
183
+ if let userSystem = userSystemPrompt, !userSystem.isEmpty {
184
+ prompt += "\nADDITIONAL CONTEXT:\n\(userSystem)\n"
185
+ }
186
+
187
+ return prompt
188
+ }
189
+
190
+ /// Build corrective prompt for retry attempts
191
+ private func buildCorrectivePrompt(previousResponse: String, validationError: String, schema: [String: Any]) -> String {
192
+ let schemaJson: String
193
+ do {
194
+ let schemaData = try JSONSerialization.data(withJSONObject: schema, options: [.prettyPrinted, .sortedKeys])
195
+ schemaJson = String(data: schemaData, encoding: .utf8) ?? "{}"
196
+ } catch {
197
+ schemaJson = "{}"
198
+ }
199
+
200
+ return """
201
+ The previous response was invalid JSON or did not match the required schema.
202
+
203
+ PREVIOUS RESPONSE:
204
+ \(previousResponse)
205
+
206
+ ERROR:
207
+ \(validationError)
208
+
209
+ REQUIRED SCHEMA:
210
+ \(schemaJson)
211
+
212
+ Fix the response and return ONLY valid JSON matching the schema. No explanations, no markdown, just the JSON.
213
+ """
214
+ }
215
+
216
+ // MARK: - Availability Check
217
+
218
+ /// Check if Apple Intelligence is available on this device
219
+ public func checkAvailability() -> (available: Bool, error: AppleIntelligenceError?) {
220
+ // Runtime check for iOS 26+
221
+ if #available(iOS 26, *) {
222
+ // Foundation Models framework is available
223
+ // Additional runtime check for Apple Intelligence capability would go here
224
+ return (true, nil)
225
+ } else {
226
+ return (false, AppleIntelligenceError(
227
+ code: .unavailable,
228
+ message: "Apple Intelligence requires iOS 26 or later. Current device is running an earlier version."
229
+ ))
230
+ }
231
+ }
232
+
233
+ // MARK: - Generation
234
+
235
+ /// Parse raw text response as JSON
236
+ private func parseJsonResponse(_ text: String) -> Result<Any, AppleIntelligenceError> {
237
+ // Clean the response - remove any markdown code blocks if present
238
+ var cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
239
+
240
+ // Remove markdown code blocks if they exist
241
+ if cleanedText.hasPrefix("```json") {
242
+ cleanedText = String(cleanedText.dropFirst(7))
243
+ } else if cleanedText.hasPrefix("```") {
244
+ cleanedText = String(cleanedText.dropFirst(3))
245
+ }
246
+
247
+ if cleanedText.hasSuffix("```") {
248
+ cleanedText = String(cleanedText.dropLast(3))
249
+ }
250
+
251
+ cleanedText = cleanedText.trimmingCharacters(in: .whitespacesAndNewlines)
252
+
253
+ guard let data = cleanedText.data(using: .utf8) else {
254
+ return .failure(AppleIntelligenceError(
255
+ code: .invalidJson,
256
+ message: "Failed to convert response to UTF-8 data"
257
+ ))
258
+ }
259
+
260
+ do {
261
+ let json = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
262
+ return .success(json)
263
+ } catch {
264
+ return .failure(AppleIntelligenceError(
265
+ code: .invalidJson,
266
+ message: "Invalid JSON: \(error.localizedDescription)"
267
+ ))
268
+ }
269
+ }
270
+
271
+ /// Generate structured JSON output using Apple Intelligence
272
+ /// - Parameters:
273
+ /// - messages: Array of conversation messages
274
+ /// - schema: JSON schema the output must conform to
275
+ /// - Returns: Result containing parsed JSON or error
276
+ @available(iOS 26, *)
277
+ public func generate(
278
+ messages: [Message],
279
+ schema: [String: Any]
280
+ ) async -> Result<Any, AppleIntelligenceError> {
281
+ // Import Foundation Models at runtime
282
+ // Note: This uses the new Foundation Models framework available in iOS 26+
283
+
284
+ // Extract system and user messages
285
+ let systemMessages = messages.filter { $0.role == .system }.map { $0.content }
286
+ let userMessages = messages.filter { $0.role == .user }.map { $0.content }
287
+
288
+ let userSystemPrompt = systemMessages.joined(separator: "\n")
289
+ let userQuery = userMessages.joined(separator: "\n")
290
+
291
+ // Build the full system prompt with schema
292
+ let systemPrompt = buildSystemPrompt(userSystemPrompt: userSystemPrompt, schema: schema)
293
+
294
+ // First attempt
295
+ var lastResponse = ""
296
+ var lastError = ""
297
+
298
+ for attempt in 0...maxRetries {
299
+ do {
300
+ let response: String
301
+
302
+ if attempt == 0 {
303
+ response = try await callLanguageModel(
304
+ systemPrompt: systemPrompt,
305
+ userPrompt: userQuery
306
+ )
307
+ } else {
308
+ // Retry with corrective prompt
309
+ let correctivePrompt = buildCorrectivePrompt(
310
+ previousResponse: lastResponse,
311
+ validationError: lastError,
312
+ schema: schema
313
+ )
314
+ response = try await callLanguageModel(
315
+ systemPrompt: systemPrompt,
316
+ userPrompt: correctivePrompt
317
+ )
318
+ }
319
+
320
+ lastResponse = response
321
+
322
+ // Parse JSON
323
+ let parseResult = parseJsonResponse(response)
324
+ switch parseResult {
325
+ case .success(let json):
326
+ // Validate against schema
327
+ let validation = validateAgainstSchema(json, schema: schema)
328
+ if validation.valid {
329
+ return .success(json)
330
+ } else {
331
+ lastError = validation.error ?? "Schema validation failed"
332
+ if attempt == maxRetries {
333
+ return .failure(AppleIntelligenceError(
334
+ code: .schemaMismatch,
335
+ message: "Schema validation failed after \(maxRetries + 1) attempts: \(lastError)"
336
+ ))
337
+ }
338
+ }
339
+ case .failure(let error):
340
+ lastError = error.message
341
+ if attempt == maxRetries {
342
+ return .failure(error)
343
+ }
344
+ }
345
+ } catch {
346
+ return .failure(AppleIntelligenceError(
347
+ code: .nativeError,
348
+ message: "Generation failed: \(error.localizedDescription)"
349
+ ))
350
+ }
351
+ }
352
+
353
+ return .failure(AppleIntelligenceError(
354
+ code: .nativeError,
355
+ message: "Generation failed after all retry attempts"
356
+ ))
357
+ }
358
+
359
+ /// Call the on-device language model
360
+ /// - Parameters:
361
+ /// - systemPrompt: The system instructions
362
+ /// - userPrompt: The user query
363
+ /// - Returns: The model's text response
364
+ @available(iOS 26, *)
365
+ private func callLanguageModel(systemPrompt: String, userPrompt: String) async throws -> String {
366
+ #if canImport(FoundationModels)
367
+ // Create a language model session
368
+ let session = LanguageModelSession()
369
+
370
+ // Build the prompt combining system and user messages
371
+ let fullPrompt = """
372
+ \(systemPrompt)
373
+
374
+ USER REQUEST:
375
+ \(userPrompt)
376
+ """
377
+
378
+ // Get response from the model
379
+ let response = try await session.respond(to: fullPrompt)
380
+ return response.content
381
+
382
+ #else
383
+ // Fallback for development/testing when FoundationModels isn't available
384
+ throw AppleIntelligenceError(
385
+ code: .unavailable,
386
+ message: "FoundationModels framework not available"
387
+ )
388
+ #endif
389
+ }
390
+
391
+
392
+ /// Generate method that returns a dictionary suitable for Capacitor bridge
393
+ @available(iOS 26, *)
394
+ public func generateForBridge(
395
+ messages: [[String: String]],
396
+ schema: [String: Any]
397
+ ) async -> [String: Any] {
398
+ // Convert raw dictionaries to Message objects
399
+ let parsedMessages = messages.compactMap { dict -> Message? in
400
+ guard let roleStr = dict["role"],
401
+ let content = dict["content"],
402
+ let role = MessageRole(rawValue: roleStr) else {
403
+ return nil
404
+ }
405
+ return Message(role: role, content: content)
406
+ }
407
+
408
+ if parsedMessages.isEmpty {
409
+ return [
410
+ "success": false,
411
+ "error": AppleIntelligenceError(
412
+ code: .nativeError,
413
+ message: "No valid messages provided"
414
+ ).asDictionary
415
+ ]
416
+ }
417
+
418
+ let result = await generate(messages: parsedMessages, schema: schema)
419
+
420
+ switch result {
421
+ case .success(let data):
422
+ return [
423
+ "success": true,
424
+ "data": data
425
+ ]
426
+ case .failure(let error):
427
+ return [
428
+ "success": false,
429
+ "error": error.asDictionary
430
+ ]
431
+ }
432
+ }
433
+ /// Generate plain text output using Apple Intelligence
434
+ /// - Parameters:
435
+ /// - messages: Array of conversation messages
436
+ /// - Returns: Result containing generated text or error
437
+ @available(iOS 26, *)
438
+ public func generateText(
439
+ messages: [Message]
440
+ ) async -> Result<String, AppleIntelligenceError> {
441
+ let systemMessages = messages.filter { $0.role == .system }.map { $0.content }
442
+ let userMessages = messages.filter { $0.role == .user }.map { $0.content }
443
+
444
+ let systemPrompt = systemMessages.joined(separator: "\n")
445
+ let userQuery = userMessages.joined(separator: "\n")
446
+
447
+ do {
448
+ let response = try await callLanguageModel(
449
+ systemPrompt: systemPrompt,
450
+ userPrompt: userQuery
451
+ )
452
+ return .success(response)
453
+ } catch {
454
+ return .failure(AppleIntelligenceError(
455
+ code: .nativeError,
456
+ message: "Generation failed: \(error.localizedDescription)"
457
+ ))
458
+ }
459
+ }
460
+
461
+ /// Generate plain text output with specific language
462
+ /// - Parameters:
463
+ /// - messages: Array of conversation messages
464
+ /// - language: Target language for the response
465
+ /// - Returns: Result containing generated text or error
466
+ @available(iOS 26, *)
467
+ public func generateTextWithLanguage(
468
+ messages: [Message],
469
+ language: String
470
+ ) async -> Result<String, AppleIntelligenceError> {
471
+ let systemMessages = messages.filter { $0.role == .system }.map { $0.content }
472
+ let userMessages = messages.filter { $0.role == .user }.map { $0.content }
473
+
474
+ // Convert language code to full language name for better model understanding
475
+ let languageMap: [String: String] = [
476
+ "en": "English",
477
+ "es": "Spanish",
478
+ "fr": "French",
479
+ "de": "German",
480
+ "ja": "Japanese",
481
+ "zh": "Chinese",
482
+ "it": "Italian",
483
+ "pt": "Portuguese",
484
+ "ru": "Russian",
485
+ "ar": "Arabic",
486
+ "ko": "Korean"
487
+ ]
488
+
489
+ let fullLanguageName = languageMap[language.lowercased()] ?? language
490
+
491
+ var systemPrompt = systemMessages.joined(separator: "\n")
492
+ // Append language instruction
493
+ if !systemPrompt.isEmpty {
494
+ systemPrompt += "\n\n"
495
+ }
496
+ systemPrompt += "IMPORTANT: You must respond ONLY in \(fullLanguageName). Do not use any other language."
497
+
498
+ let userQuery = userMessages.joined(separator: "\n")
499
+
500
+ do {
501
+ let response = try await callLanguageModel(
502
+ systemPrompt: systemPrompt,
503
+ userPrompt: userQuery
504
+ )
505
+ return .success(response)
506
+ } catch {
507
+ return .failure(AppleIntelligenceError(
508
+ code: .nativeError,
509
+ message: "Generation failed: \(error.localizedDescription)"
510
+ ))
511
+ }
512
+ }
513
+
514
+ /// Generate text bridge helper
515
+ @available(iOS 26, *)
516
+ public func generateTextForBridge(
517
+ messages: [[String: String]]
518
+ ) async -> [String: Any] {
519
+ let parsedMessages = messages.compactMap { dict -> Message? in
520
+ guard let roleStr = dict["role"],
521
+ let content = dict["content"],
522
+ let role = MessageRole(rawValue: roleStr) else {
523
+ return nil
524
+ }
525
+ return Message(role: role, content: content)
526
+ }
527
+
528
+ if parsedMessages.isEmpty {
529
+ return [
530
+ "success": false,
531
+ "error": AppleIntelligenceError(
532
+ code: .nativeError,
533
+ message: "No valid messages provided"
534
+ ).asDictionary
535
+ ]
536
+ }
537
+
538
+ let result = await generateText(messages: parsedMessages)
539
+
540
+ switch result {
541
+ case .success(let content):
542
+ return [
543
+ "success": true,
544
+ "content": content
545
+ ]
546
+ case .failure(let error):
547
+ return [
548
+ "success": false,
549
+ "error": error.asDictionary
550
+ ]
551
+ }
552
+ }
553
+
554
+ /// Generate text with language bridge helper
555
+ @available(iOS 26, *)
556
+ public func generateTextWithLanguageForBridge(
557
+ messages: [[String: String]],
558
+ language: String
559
+ ) async -> [String: Any] {
560
+ let parsedMessages = messages.compactMap { dict -> Message? in
561
+ guard let roleStr = dict["role"],
562
+ let content = dict["content"],
563
+ let role = MessageRole(rawValue: roleStr) else {
564
+ return nil
565
+ }
566
+ return Message(role: role, content: content)
567
+ }
568
+
569
+ if parsedMessages.isEmpty {
570
+ return [
571
+ "success": false,
572
+ "error": AppleIntelligenceError(
573
+ code: .nativeError,
574
+ message: "No valid messages provided"
575
+ ).asDictionary
576
+ ]
577
+ }
578
+
579
+ let result = await generateTextWithLanguage(messages: parsedMessages, language: language)
580
+
581
+ switch result {
582
+ case .success(let content):
583
+ return [
584
+ "success": true,
585
+ "content": content
586
+ ]
587
+ case .failure(let error):
588
+ return [
589
+ "success": false,
590
+ "error": error.asDictionary
591
+ ]
592
+ }
593
+ }
594
+ }