@tanstack-ai-browser/afm 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +33 -0
  3. package/README.md +75 -2
  4. package/dist/adapter.d.ts +107 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +522 -0
  7. package/dist/electron.d.ts +29 -0
  8. package/dist/electron.d.ts.map +1 -0
  9. package/dist/electron.js +136 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +12 -0
  13. package/dist/json-value.d.ts +9 -0
  14. package/dist/json-value.d.ts.map +1 -0
  15. package/dist/json-value.js +19 -0
  16. package/dist/messages.d.ts +12 -0
  17. package/dist/messages.d.ts.map +1 -0
  18. package/dist/messages.js +29 -0
  19. package/dist/model-meta.d.ts +23 -0
  20. package/dist/model-meta.d.ts.map +1 -0
  21. package/dist/model-meta.js +20 -0
  22. package/dist/native.d.ts +175 -0
  23. package/dist/native.d.ts.map +1 -0
  24. package/dist/native.js +21 -0
  25. package/dist/schema.d.ts +17 -0
  26. package/dist/schema.d.ts.map +1 -0
  27. package/dist/schema.js +133 -0
  28. package/dist/validate.d.ts +2 -0
  29. package/dist/validate.d.ts.map +1 -0
  30. package/dist/validate.js +124 -0
  31. package/electron/AfmNode.swift +81 -0
  32. package/electron/afm.c +94 -0
  33. package/electron/afm.node +0 -0
  34. package/electron/afm.node.sha256 +1 -0
  35. package/electron/build.mjs +54 -0
  36. package/electron/preload.cjs +35 -0
  37. package/electron/sources-hash.mjs +40 -0
  38. package/ios/Sources/AfmCore/Bridge.swift +354 -0
  39. package/ios/Sources/AfmCore/Conversation.swift +49 -0
  40. package/ios/Sources/AfmCore/Schema.swift +90 -0
  41. package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
  42. package/package.json +97 -3
  43. package/src/adapter.test.ts +428 -0
  44. package/src/adapter.ts +616 -0
  45. package/src/electron.ts +193 -0
  46. package/src/index.ts +28 -0
  47. package/src/json-value.test.ts +26 -0
  48. package/src/json-value.ts +17 -0
  49. package/src/messages.ts +37 -0
  50. package/src/model-meta.ts +37 -0
  51. package/src/native-build.test.ts +13 -0
  52. package/src/native.ts +128 -0
  53. package/src/schema.test.ts +146 -0
  54. package/src/schema.ts +153 -0
  55. package/src/validate.test.ts +38 -0
  56. package/src/validate.ts +112 -0
@@ -0,0 +1,354 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// Apple's on-device model for a JavaScript host: the Capacitor plugin on iOS, the
5
+ /// Electron addon on macOS. Calls and events are JSON-shaped dictionaries, so each host
6
+ /// only passes them through.
7
+ ///
8
+ /// Tools are the framework's own: a session calls them in the middle of a response.
9
+ /// Each tool here stands in for one the JavaScript side runs. When the model calls
10
+ /// tools, the bridge sends a `toolCalls` event once every call of the model's batch
11
+ /// has started (the session's transcript lists the batch), and each call waits for
12
+ /// `submitToolResult`. The response then goes on where it was: the session keeps
13
+ /// everything it has read, and nothing is sent to it again.
14
+ ///
15
+ /// Events, each with the `sessionId` and the `turn` of the `respond` call:
16
+ /// - `{ type: "delta", text }`: more of the response.
17
+ /// - `{ type: "toolCalls", calls: [{ id, name, arguments }] }`: `arguments` in JSON.
18
+ /// - `{ type: "done", text, usage, asText }`: the response, and its tokens (iOS/macOS 27):
19
+ /// `inputTokens` and `outputTokens` over every pass of the model (a tool call's
20
+ /// too), and `cachedTokens` only for a session without tools (see `usage()`).
21
+ /// `asText` when a schema's answer was written as text (a permissive session's
22
+ /// fallback, see `run`): not held to the schema, so the caller checks it.
23
+ /// - `{ type: "error", code, message }`.
24
+ @available(iOS 26.0, macOS 26.0, *)
25
+ public final class AfmBridge: @unchecked Sendable {
26
+ public typealias Emit = @Sendable ([String: Any]) -> Void
27
+
28
+ private let emit: Emit
29
+ private let lock = NSLock()
30
+ private var sessions: [String: BridgeSession] = [:]
31
+
32
+ public init(emit: @escaping Emit) {
33
+ self.emit = emit
34
+ }
35
+
36
+ /// The model with the guardrails `options` names: "permissiveContentTransformations",
37
+ /// or the default ones. Apple's default guardrails can block ordinary text that
38
+ /// sounds unsafe out of context (a card game's "Searing Blood", "Play with Fire").
39
+ static func model(_ options: [String: Any]) -> SystemLanguageModel {
40
+ switch options["guardrails"] as? String {
41
+ case "permissiveContentTransformations": return SystemLanguageModel(guardrails: .permissiveContentTransformations)
42
+ default: return .default
43
+ }
44
+ }
45
+
46
+ /// Whether the model can run here, why not, and the tokens a session holds:
47
+ /// the model with the guardrails `options` names.
48
+ public static func availability(_ options: [String: Any] = [:]) -> [String: Any] {
49
+ let model = model(options)
50
+ var result: [String: Any]
51
+ switch model.availability {
52
+ case .available:
53
+ result = ["available": true]
54
+ case .unavailable(let reason):
55
+ let name: String
56
+ switch reason {
57
+ case .deviceNotEligible: name = "deviceNotEligible"
58
+ case .appleIntelligenceNotEnabled: name = "appleIntelligenceNotEnabled"
59
+ case .modelNotReady: name = "modelNotReady"
60
+ @unknown default: name = "unavailable"
61
+ }
62
+ result = ["available": false, "reason": name]
63
+ }
64
+ if #available(iOS 26.4, macOS 26.4, *) {
65
+ result["contextSize"] = model.contextSize
66
+ }
67
+ return result
68
+ }
69
+
70
+ /// Starts a session: `sessionId`, `instructions`, `tools` ([{ name, description,
71
+ /// parameters }]), `messages`, the conversation it starts from, and `guardrails`
72
+ /// ("default" or "permissiveContentTransformations"). A session with the same id
73
+ /// is ended first.
74
+ public func createSession(_ options: [String: Any]) throws {
75
+ let id = try Self.string(options, "sessionId")
76
+ deleteSession(id)
77
+ let state = BridgeSession(id: id, emit: emit)
78
+ let tools = try (options["tools"] as? [Any] ?? []).map { item -> BridgedTool in
79
+ let spec = try Schema.object(item, "tool")
80
+ return BridgedTool(
81
+ name: spec["name"] as? String ?? "",
82
+ description: spec["description"] as? String ?? "",
83
+ parameters: try Schema.generationSchema(spec["parameters"]),
84
+ state: state
85
+ )
86
+ }
87
+ let transcript = try Conversation.transcript(
88
+ instructions: options["instructions"] as? String,
89
+ tools: tools.map { Transcript.ToolDefinition(tool: $0) },
90
+ messages: options["messages"] as? [Any] ?? []
91
+ )
92
+ state.session = LanguageModelSession(model: Self.model(options), tools: tools, transcript: transcript)
93
+ state.hasTools = !tools.isEmpty
94
+ state.permissive = (options["guardrails"] as? String) == "permissiveContentTransformations"
95
+ lock.withLock { sessions[id] = state }
96
+ }
97
+
98
+ /// Loads the session's model ahead of its first prompt.
99
+ public func prewarm(_ options: [String: Any]) throws {
100
+ try session(options).session.prewarm()
101
+ }
102
+
103
+ /// Prompts a session (`sessionId`, `prompt`, `turn`, `options`: `temperature`,
104
+ /// `maximumResponseTokens`). The response comes as events. With a `schema` (as
105
+ /// tools' parameters take it), the answer is held to it, and `done` has it in JSON.
106
+ public func respond(_ options: [String: Any]) throws {
107
+ let state = try session(options)
108
+ let settings = options["options"] as? [String: Any] ?? [:]
109
+ var generation = GenerationOptions(
110
+ temperature: (settings["temperature"] as? NSNumber)?.doubleValue,
111
+ maximumResponseTokens: (settings["maximumResponseTokens"] as? NSNumber)?.intValue
112
+ )
113
+ // Whether the model may call tools ("allowed"), must ("required") or
114
+ // mustn't ("disallowed"): iOS/macOS 27. Left out, the framework decides.
115
+ if #available(iOS 27.0, macOS 27.0, *), let mode = settings["toolCallingMode"] as? String {
116
+ switch mode {
117
+ case "required": generation.toolCallingMode = .required
118
+ case "disallowed": generation.toolCallingMode = .disallowed
119
+ default: generation.toolCallingMode = .allowed
120
+ }
121
+ }
122
+ let schema = try options["schema"].map(Schema.generationSchema)
123
+ try state.respond(prompt: options["prompt"] as? String ?? "", turn: options["turn"] as? Int ?? 0, options: generation, schema: schema)
124
+ }
125
+
126
+ /// A tool's result (`sessionId`, `callId`, `output`): the waiting call returns it.
127
+ public func submitToolResult(_ options: [String: Any]) throws {
128
+ try session(options).resolve(callId: try Self.string(options, "callId"), output: options["output"] as? String ?? "")
129
+ }
130
+
131
+ /// Stops a session's response; the session stays.
132
+ public func cancel(_ options: [String: Any]) throws {
133
+ try session(options).cancel()
134
+ }
135
+
136
+ /// Ends a session.
137
+ public func deleteSession(_ id: String) {
138
+ lock.withLock { sessions.removeValue(forKey: id) }?.cancel()
139
+ }
140
+
141
+ private func session(_ options: [String: Any]) throws -> BridgeSession {
142
+ let id = try Self.string(options, "sessionId")
143
+ guard let state = lock.withLock({ sessions[id] }) else {
144
+ throw Schema.Unsupported(description: "No session \(id)")
145
+ }
146
+ return state
147
+ }
148
+
149
+ private static func string(_ options: [String: Any], _ key: String) throws -> String {
150
+ guard let value = options[key] as? String else { throw Schema.Unsupported(description: "Missing \(key)") }
151
+ return value
152
+ }
153
+ }
154
+
155
+ /// A session and its response in progress, with the tool calls waiting for results.
156
+ @available(iOS 26.0, macOS 26.0, *)
157
+ final class BridgeSession: @unchecked Sendable {
158
+ let id: String
159
+ var session: LanguageModelSession!
160
+ var hasTools = false
161
+ /// Permissive guardrails: structured answers come as text (see `AfmBridge.respond`).
162
+ var permissive = false
163
+
164
+ private let emit: AfmBridge.Emit
165
+ private let lock = NSLock()
166
+ private var task: Task<Void, Never>?
167
+ private var turn = 0
168
+ private var waiting: [Call] = []
169
+ private var answered: Set<String> = []
170
+
171
+ private struct Call {
172
+ let id: String
173
+ let name: String
174
+ let arguments: String
175
+ var reported: Bool
176
+ let continuation: CheckedContinuation<String, Error>
177
+ }
178
+
179
+ init(id: String, emit: @escaping AfmBridge.Emit) {
180
+ self.id = id
181
+ self.emit = emit
182
+ }
183
+
184
+ func respond(prompt: String, turn: Int, options: GenerationOptions, schema: GenerationSchema?) throws {
185
+ try lock.withLock {
186
+ guard task == nil else { throw Schema.Unsupported(description: "Session \(id) is responding") }
187
+ self.turn = turn
188
+ answered = []
189
+ task = Task { [self] in await run(prompt, options, schema) }
190
+ }
191
+ }
192
+
193
+ /// What a prompt adds to ask for a schema's answer as text: a JSON value (an object,
194
+ /// an array or a primitive, as the schema says) that matches it.
195
+ static func asJSON(_ schema: GenerationSchema) -> String {
196
+ let json = (try? JSONEncoder().encode(schema)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
197
+ return "\n\nAnswer with only a JSON value, and no other text, that matches this JSON Schema:\n\(json)"
198
+ }
199
+
200
+ private func run(_ prompt: String, _ options: GenerationOptions, _ schema: GenerationSchema?) async {
201
+ let before = usage()
202
+ var text = ""
203
+ var asText = false
204
+ do {
205
+ if let schema {
206
+ do {
207
+ text = try await session.respond(to: prompt, schema: schema, options: options).content.jsonString
208
+ } catch where permissive && Self.code(error) == "guardrailViolation" {
209
+ // Permissive guardrails cover String responses only: Apple documents that
210
+ // any other response (a schema's) gets the default guardrails, which block
211
+ // text such as "add 2 Searing Blood". So a blocked answer is asked for again
212
+ // as text, a JSON value to the schema in JSON Schema. `done` says so
213
+ // (`asText`): the framework didn't hold it to the schema, the caller checks it.
214
+ text = try await session.respond(to: prompt + Self.asJSON(schema), options: options).content
215
+ asText = true
216
+ }
217
+ } else {
218
+ for try await snapshot in session.streamResponse(to: prompt, options: options) {
219
+ let content = snapshot.content
220
+ let delta = content.hasPrefix(text) ? String(content.dropFirst(text.count)) : content
221
+ text = content
222
+ if !delta.isEmpty { send(["type": "delta", "text": delta]) }
223
+ }
224
+ }
225
+ // A stopped response's stream may just end.
226
+ try Task.checkCancellation()
227
+ var done: [String: Any] = ["type": "done", "text": text]
228
+ if asText { done["asText"] = true }
229
+ if let before, let after = usage() {
230
+ var usage: [String: Any] = [
231
+ "inputTokens": after.input - before.input,
232
+ "outputTokens": after.output - before.output
233
+ ]
234
+ if !hasTools { usage["cachedTokens"] = after.cached - before.cached }
235
+ done["usage"] = usage
236
+ }
237
+ send(done)
238
+ } catch is CancellationError {
239
+ send(["type": "error", "code": "cancelled", "message": "The response was stopped"])
240
+ } catch {
241
+ send(["type": "error", "code": Self.code(error), "message": String(describing: error)])
242
+ }
243
+ lock.withLock { task = nil }
244
+ }
245
+
246
+ /// A tool call: waits for its result, reporting the model's batch once it's whole.
247
+ func call(name: String, arguments: GeneratedContent) async throws -> String {
248
+ let json = arguments.jsonString
249
+ return try await withTaskCancellationHandler {
250
+ try await withCheckedThrowingContinuation { continuation in
251
+ lock.withLock {
252
+ // Cancelled before this registers: the cancellation handler has
253
+ // already run (under this lock), so it would never see this call.
254
+ if Task.isCancelled {
255
+ continuation.resume(throwing: CancellationError())
256
+ return
257
+ }
258
+ // The model's batch of calls, from the transcript: its ids, and its size.
259
+ var batch: [Transcript.ToolCall] = []
260
+ if case .toolCalls(let calls) = session.transcript.last { batch = Array(calls) }
261
+ let taken = Set(waiting.map(\.id)).union(answered)
262
+ let match = batch.first { !taken.contains($0.id) && $0.toolName == name && $0.arguments.jsonString == json }
263
+ ?? batch.first { !taken.contains($0.id) && $0.toolName == name }
264
+ waiting.append(Call(id: match?.id ?? UUID().uuidString, name: name, arguments: json, reported: false, continuation: continuation))
265
+ // Every call of the batch has started (or the batch can't be read).
266
+ let started = Set(waiting.map(\.id)).union(answered)
267
+ if match == nil || batch.allSatisfy({ started.contains($0.id) }) { reportWaiting() }
268
+ }
269
+ }
270
+ } onCancel: {
271
+ self.failWaiting(CancellationError())
272
+ }
273
+ }
274
+
275
+ /// Sends the waiting calls not sent yet. Called holding the lock.
276
+ private func reportWaiting() {
277
+ let calls = waiting.indices.filter { !waiting[$0].reported }
278
+ guard !calls.isEmpty else { return }
279
+ for index in calls { waiting[index].reported = true }
280
+ send(["type": "toolCalls", "calls": calls.map { ["id": waiting[$0].id, "name": waiting[$0].name, "arguments": waiting[$0].arguments] }])
281
+ }
282
+
283
+ func resolve(callId: String, output: String) throws {
284
+ let continuation = try lock.withLock { () -> CheckedContinuation<String, Error> in
285
+ guard let index = waiting.firstIndex(where: { $0.id == callId }) else {
286
+ throw Schema.Unsupported(description: "No tool call \(callId) is waiting")
287
+ }
288
+ answered.insert(callId)
289
+ return waiting.remove(at: index).continuation
290
+ }
291
+ continuation.resume(returning: output)
292
+ }
293
+
294
+ func cancel() {
295
+ let task = lock.withLock { self.task }
296
+ task?.cancel()
297
+ failWaiting(CancellationError())
298
+ }
299
+
300
+ private func failWaiting(_ error: Error) {
301
+ let calls = lock.withLock { () -> [Call] in
302
+ defer { waiting = [] }
303
+ return waiting
304
+ }
305
+ for call in calls { call.continuation.resume(throwing: error) }
306
+ }
307
+
308
+ private func send(_ event: [String: Any]) {
309
+ var event = event
310
+ event["sessionId"] = id
311
+ event["turn"] = turn
312
+ emit(event)
313
+ }
314
+
315
+ /// The session's tokens so far (iOS/macOS 27), over every pass of the model:
316
+ /// `respond`'s usage is the difference, a tool call's pass included, which the
317
+ /// response's own usage leaves out.
318
+ ///
319
+ /// The cached count is right for a session without tools: 0 for a cold
320
+ /// prompt, the new message's tokens fewer for a follow-up, and 0 again once
321
+ /// the framework has dropped the cache (a few seconds idle). With tools, the
322
+ /// framework counts every token but one as cached, whether the cache was
323
+ /// used (TTFT 1.5 s) or had been dropped (5.9 s), so it isn't reported.
324
+ private func usage() -> (input: Int, cached: Int, output: Int)? {
325
+ guard #available(iOS 27.0, macOS 27.0, *) else { return nil }
326
+ let usage = session.usage
327
+ return (usage.input.totalTokenCount, usage.input.cachedTokenCount, usage.output.totalTokenCount)
328
+ }
329
+
330
+ /// An error's case name (`exceededContextWindowSize`, `guardrailViolation`, …).
331
+ static func code(_ error: Error) -> String {
332
+ // An enum error's case: LanguageModelError (iOS/macOS 27) describes itself with its
333
+ // message ("Response may contain sensitive or unsafe content"), not its case.
334
+ if let label = Mirror(reflecting: error).children.first?.label { return label }
335
+ let text = String(describing: error)
336
+ return String(text.prefix { $0 != "(" && $0 != ":" && $0 != " " })
337
+ }
338
+ }
339
+
340
+ /// A tool the JavaScript side runs, as the framework sees it.
341
+ @available(iOS 26.0, macOS 26.0, *)
342
+ struct BridgedTool: Tool {
343
+ let name: String
344
+ let description: String
345
+ let parameters: GenerationSchema
346
+ /// Weak: the session's `LanguageModelSession` holds its tools, so a strong
347
+ /// reference back would keep a deleted session alive.
348
+ weak let state: BridgeSession?
349
+
350
+ func call(arguments: GeneratedContent) async throws -> String {
351
+ guard let state else { throw CancellationError() }
352
+ return try await state.call(name: name, arguments: arguments)
353
+ }
354
+ }
@@ -0,0 +1,49 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// A session's starting transcript: its instructions, its tools' definitions, and the
5
+ /// conversation before the prompt, as the adapter sends it (src/messages.ts):
6
+ ///
7
+ /// - `{ role: "user", text }`
8
+ /// - `{ role: "assistant", text, toolCalls: [{ id, name, arguments }] }`, `arguments` in JSON
9
+ /// - `{ role: "tool", toolCallId, name, text }`
10
+ @available(iOS 26.0, macOS 26.0, *)
11
+ enum Conversation {
12
+ static func transcript(instructions: String?, tools: [Transcript.ToolDefinition], messages: [Any]) throws -> Transcript {
13
+ var entries: [Transcript.Entry] = []
14
+ if (instructions?.isEmpty == false) || !tools.isEmpty {
15
+ entries.append(.instructions(Transcript.Instructions(segments: segments(instructions ?? ""), toolDefinitions: tools)))
16
+ }
17
+ for item in messages {
18
+ let message = try Schema.object(item, "message")
19
+ let text = message["text"] as? String ?? ""
20
+ switch message["role"] as? String {
21
+ case "user":
22
+ entries.append(.prompt(Transcript.Prompt(segments: segments(text))))
23
+ case "assistant":
24
+ if !text.isEmpty {
25
+ entries.append(.response(Transcript.Response(assetIDs: [], segments: segments(text))))
26
+ }
27
+ let calls = try (message["toolCalls"] as? [Any] ?? []).map { item -> Transcript.ToolCall in
28
+ let call = try Schema.object(item, "tool call")
29
+ let arguments = try GeneratedContent(json: call["arguments"] as? String ?? "{}")
30
+ return Transcript.ToolCall(id: call["id"] as? String ?? UUID().uuidString, toolName: call["name"] as? String ?? "", arguments: arguments)
31
+ }
32
+ if !calls.isEmpty { entries.append(.toolCalls(Transcript.ToolCalls(calls))) }
33
+ case "tool":
34
+ entries.append(.toolOutput(Transcript.ToolOutput(
35
+ id: message["toolCallId"] as? String ?? "",
36
+ toolName: message["name"] as? String ?? "",
37
+ segments: segments(text)
38
+ )))
39
+ default:
40
+ throw Schema.Unsupported(description: "Unknown message role")
41
+ }
42
+ }
43
+ return Transcript(entries: entries)
44
+ }
45
+
46
+ static func segments(_ text: String) -> [Transcript.Segment] {
47
+ [.text(Transcript.TextSegment(content: text))]
48
+ }
49
+ }
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+ import FoundationModels
3
+
4
+ /// A tool's parameters, or a structured answer's shape, as the adapter sends it: JSON
5
+ /// Schema already read into an ordered form (src/schema.ts), so a property keeps its
6
+ /// place (the model writes properties in the order it's given them). The kinds:
7
+ ///
8
+ /// - `object`: `name`, `description`, `properties` ([{ name, description, optional, schema }])
9
+ /// - `array`: `items`, `minimumElements`, `maximumElements`
10
+ /// - `string`: `choices` (an enum), `constant`
11
+ /// - `integer`, `number`: `minimum`, `maximum`
12
+ /// - `boolean`, `null`
13
+ /// - `anyOf`: `name`, `description`, `choices` (schemas)
14
+ /// - `reference`: `name`, one of the definitions
15
+ @available(iOS 26.0, macOS 26.0, *)
16
+ enum Schema {
17
+ struct Unsupported: Error, CustomStringConvertible {
18
+ let description: String
19
+ }
20
+
21
+ /// A generation schema from a root and its definitions (the `reference` targets).
22
+ static func generationSchema(_ value: Any?) throws -> GenerationSchema {
23
+ let json = try object(value, "schema")
24
+ let definitions = try (json["definitions"] as? [Any] ?? []).map(dynamicSchema)
25
+ return try GenerationSchema(root: dynamicSchema(json["root"]), dependencies: definitions)
26
+ }
27
+
28
+ static func dynamicSchema(_ value: Any?) throws -> DynamicGenerationSchema {
29
+ let json = try object(value, "schema")
30
+ let kind = json["kind"] as? String ?? ""
31
+ switch kind {
32
+ case "object":
33
+ let properties = try (json["properties"] as? [Any] ?? []).map { item -> DynamicGenerationSchema.Property in
34
+ let property = try object(item, "property")
35
+ return DynamicGenerationSchema.Property(
36
+ name: property["name"] as? String ?? "",
37
+ description: property["description"] as? String,
38
+ schema: try dynamicSchema(property["schema"]),
39
+ isOptional: property["optional"] as? Bool ?? false
40
+ )
41
+ }
42
+ return DynamicGenerationSchema(name: json["name"] as? String ?? "object", description: json["description"] as? String, properties: properties)
43
+ case "array":
44
+ return DynamicGenerationSchema(
45
+ arrayOf: try dynamicSchema(json["items"]),
46
+ minimumElements: json["minimumElements"] as? Int,
47
+ maximumElements: json["maximumElements"] as? Int
48
+ )
49
+ case "string":
50
+ if let constant = json["constant"] as? String {
51
+ return DynamicGenerationSchema(type: String.self, guides: [.constant(constant)])
52
+ }
53
+ if let choices = json["choices"] as? [String], !choices.isEmpty {
54
+ return DynamicGenerationSchema(type: String.self, guides: [.anyOf(choices)])
55
+ }
56
+ return DynamicGenerationSchema(type: String.self)
57
+ case "integer":
58
+ let minimum = (json["minimum"] as? NSNumber)?.intValue, maximum = (json["maximum"] as? NSNumber)?.intValue
59
+ var guides: [GenerationGuide<Int>] = []
60
+ if let minimum, let maximum { guides.append(.range(minimum...max(minimum, maximum))) }
61
+ else if let minimum { guides.append(.minimum(minimum)) }
62
+ else if let maximum { guides.append(.maximum(maximum)) }
63
+ return DynamicGenerationSchema(type: Int.self, guides: guides)
64
+ case "number":
65
+ let minimum = (json["minimum"] as? NSNumber)?.doubleValue, maximum = (json["maximum"] as? NSNumber)?.doubleValue
66
+ var guides: [GenerationGuide<Double>] = []
67
+ if let minimum, let maximum { guides.append(.range(minimum...max(minimum, maximum))) }
68
+ else if let minimum { guides.append(.minimum(minimum)) }
69
+ else if let maximum { guides.append(.maximum(maximum)) }
70
+ return DynamicGenerationSchema(type: Double.self, guides: guides)
71
+ case "boolean":
72
+ return DynamicGenerationSchema(type: Bool.self)
73
+ case "null":
74
+ if #available(iOS 26.4, macOS 26.4, *) { return .null }
75
+ throw Unsupported(description: "A null schema needs iOS 26.4 or macOS 26.4")
76
+ case "anyOf":
77
+ let choices = try (json["choices"] as? [Any] ?? []).map(dynamicSchema)
78
+ return DynamicGenerationSchema(name: json["name"] as? String ?? "choice", description: json["description"] as? String, anyOf: choices)
79
+ case "reference":
80
+ return DynamicGenerationSchema(referenceTo: json["name"] as? String ?? "")
81
+ default:
82
+ throw Unsupported(description: "Unknown schema kind \"\(kind)\"")
83
+ }
84
+ }
85
+
86
+ static func object(_ value: Any?, _ what: String) throws -> [String: Any] {
87
+ guard let object = value as? [String: Any] else { throw Unsupported(description: "Expected a \(what) object") }
88
+ return object
89
+ }
90
+ }
@@ -0,0 +1,89 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import AfmCore
4
+
5
+ /// The Capacitor plugin: AfmBridge's calls, with its events sent to
6
+ /// JavaScript as `event`. Below iOS 26 the model is unavailable.
7
+ @objc(AfmPlugin)
8
+ public class AfmPlugin: CAPPlugin, CAPBridgedPlugin {
9
+ public let identifier = "AfmPlugin"
10
+ public let jsName = "Afm"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "availability", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "createSession", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "prewarm", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "respond", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "submitToolResult", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "cancel", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "deleteSession", returnType: CAPPluginReturnPromise)
19
+ ]
20
+
21
+ /// The AfmBridge, made on first use.
22
+ private var afmObject: AnyObject?
23
+
24
+ @available(iOS 26.0, *)
25
+ private var afm: AfmBridge {
26
+ if let bridge = afmObject as? AfmBridge { return bridge }
27
+ let bridge = AfmBridge { [weak self] event in
28
+ self?.notifyListeners("event", data: event)
29
+ }
30
+ afmObject = bridge
31
+ return bridge
32
+ }
33
+
34
+ @objc func availability(_ call: CAPPluginCall) {
35
+ guard #available(iOS 26.0, *) else {
36
+ call.resolve(["available": false, "reason": "unsupportedOS"])
37
+ return
38
+ }
39
+ call.resolve(AfmBridge.availability(call.options as? [String: Any] ?? [:]))
40
+ }
41
+
42
+ @objc func createSession(_ call: CAPPluginCall) {
43
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
44
+ perform(call) { try self.afm.createSession($0) }
45
+ }
46
+
47
+ @objc func prewarm(_ call: CAPPluginCall) {
48
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
49
+ perform(call) { try self.afm.prewarm($0) }
50
+ }
51
+
52
+ @objc func respond(_ call: CAPPluginCall) {
53
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
54
+ perform(call) { try self.afm.respond($0) }
55
+ }
56
+
57
+ @objc func submitToolResult(_ call: CAPPluginCall) {
58
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
59
+ perform(call) { try self.afm.submitToolResult($0) }
60
+ }
61
+
62
+ @objc func cancel(_ call: CAPPluginCall) {
63
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
64
+ perform(call) { try self.afm.cancel($0) }
65
+ }
66
+
67
+ @objc func deleteSession(_ call: CAPPluginCall) {
68
+ guard #available(iOS 26.0, *) else { return unsupported(call) }
69
+ perform(call) { self.afm.deleteSession($0["sessionId"] as? String ?? "") }
70
+ }
71
+
72
+ private func unsupported(_ call: CAPPluginCall) {
73
+ call.reject("Apple's on-device model needs iOS 26 or later", "unsupportedOS")
74
+ }
75
+
76
+ /// Runs a bridge call with the call's options, resolving or rejecting it.
77
+ private func perform(_ call: CAPPluginCall, _ body: ([String: Any]) throws -> Void) {
78
+ var options: [String: Any] = [:]
79
+ for (key, value) in call.options {
80
+ if let key = key as? String { options[key] = value }
81
+ }
82
+ do {
83
+ try body(options)
84
+ call.resolve()
85
+ } catch {
86
+ call.reject(String(describing: error))
87
+ }
88
+ }
89
+ }