@tanstack-ai-browser/afm 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/Package.swift +33 -0
- package/README.md +62 -2
- package/dist/adapter.d.ts +96 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +490 -0
- package/dist/electron.d.ts +22 -0
- package/dist/electron.d.ts.map +1 -0
- package/dist/electron.js +131 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/messages.d.ts +12 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +29 -0
- package/dist/model-meta.d.ts +23 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +20 -0
- package/dist/native.d.ts +163 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +21 -0
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +133 -0
- package/electron/AfmNode.swift +81 -0
- package/electron/afm.c +94 -0
- package/electron/afm.node +0 -0
- package/electron/afm.node.sha256 +1 -0
- package/electron/build.mjs +54 -0
- package/electron/preload.cjs +35 -0
- package/electron/sources-hash.mjs +40 -0
- package/ios/Sources/AfmCore/Bridge.swift +315 -0
- package/ios/Sources/AfmCore/Conversation.swift +49 -0
- package/ios/Sources/AfmCore/Schema.swift +90 -0
- package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
- package/package.json +98 -4
- package/src/adapter.test.ts +380 -0
- package/src/adapter.ts +574 -0
- package/src/electron.ts +180 -0
- package/src/index.ts +27 -0
- package/src/messages.ts +37 -0
- package/src/model-meta.ts +37 -0
- package/src/native-build.test.ts +13 -0
- package/src/native.ts +119 -0
- package/src/schema.test.ts +146 -0
- package/src/schema.ts +153 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Builds the Node-API addon serveAfm() loads, electron/afm.node: the Swift bridge
|
|
2
|
+
// (ios/Sources/AfmCore, which the Capacitor plugin uses too), AfmNode.swift (its C
|
|
3
|
+
// functions) and afm.c (Node-API), in one Mach-O bundle for Apple silicon Macs.
|
|
4
|
+
// Node-API is the same in every Node and Electron, so one build loads in all of them.
|
|
5
|
+
// Needs Xcode on macOS.
|
|
6
|
+
//
|
|
7
|
+
// node electron/build.mjs
|
|
8
|
+
import { execFileSync } from "node:child_process"
|
|
9
|
+
import { readdirSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { createRequire } from "node:module"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
const core = join(here, "..", "ios", "Sources", "AfmCore")
|
|
17
|
+
const headers = join(dirname(require.resolve("node-api-headers/package.json")), "include")
|
|
18
|
+
// The oldest macOS the addon loads on; Apple's model needs macOS 26 (checked at run time).
|
|
19
|
+
const target = "arm64-apple-macos13.0"
|
|
20
|
+
|
|
21
|
+
const run = (command, args) => execFileSync("xcrun", [command, ...args], { stdio: "inherit" })
|
|
22
|
+
|
|
23
|
+
const object = join(here, "afm.o")
|
|
24
|
+
run("clang", ["-c", "-O2", "-target", target, "-I", headers, join(here, "afm.c"), "-o", object])
|
|
25
|
+
run("swiftc", [
|
|
26
|
+
"-O",
|
|
27
|
+
"-emit-library",
|
|
28
|
+
"-module-name",
|
|
29
|
+
"AfmNode",
|
|
30
|
+
"-target",
|
|
31
|
+
target,
|
|
32
|
+
...readdirSync(core)
|
|
33
|
+
.filter((file) => file.endsWith(".swift"))
|
|
34
|
+
.map((file) => join(core, file)),
|
|
35
|
+
join(here, "AfmNode.swift"),
|
|
36
|
+
object,
|
|
37
|
+
"-o",
|
|
38
|
+
join(here, "afm.node"),
|
|
39
|
+
// Node-API comes from the process that loads the addon.
|
|
40
|
+
"-Xlinker",
|
|
41
|
+
"-undefined",
|
|
42
|
+
"-Xlinker",
|
|
43
|
+
"dynamic_lookup",
|
|
44
|
+
// macOS before 26 has no FoundationModels: the addon still loads there.
|
|
45
|
+
"-Xlinker",
|
|
46
|
+
"-weak_framework",
|
|
47
|
+
"-Xlinker",
|
|
48
|
+
"FoundationModels"
|
|
49
|
+
])
|
|
50
|
+
execFileSync("rm", ["-f", object])
|
|
51
|
+
// Which sources this addon is from: committed with it, and checked by a test.
|
|
52
|
+
const { hashFile, sourcesHash } = await import("./sources-hash.mjs")
|
|
53
|
+
writeFileSync(hashFile, `${sourcesHash()}\n`)
|
|
54
|
+
console.log(`Built ${join(here, "afm.node")}`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The preload serveAfm() adds to the app's pages: Apple's on-device model as
|
|
2
|
+
// window.afm (the AfmNative interface), which afmText() finds on its own. The main
|
|
3
|
+
// process runs the model; this only talks to it. CommonJS needing nothing but
|
|
4
|
+
// Electron, as a sandboxed preload must.
|
|
5
|
+
"use strict"
|
|
6
|
+
|
|
7
|
+
const { contextBridge, ipcRenderer } = require("electron")
|
|
8
|
+
|
|
9
|
+
const call =
|
|
10
|
+
(method) =>
|
|
11
|
+
(options = {}) =>
|
|
12
|
+
ipcRenderer.invoke("afm:call", method, options)
|
|
13
|
+
|
|
14
|
+
const afm = {
|
|
15
|
+
availability: call("availability"),
|
|
16
|
+
createSession: call("createSession"),
|
|
17
|
+
prewarm: call("prewarm"),
|
|
18
|
+
respond: call("respond"),
|
|
19
|
+
submitToolResult: call("submitToolResult"),
|
|
20
|
+
cancel: call("cancel"),
|
|
21
|
+
deleteSession: call("deleteSession"),
|
|
22
|
+
addListener(_name, listener) {
|
|
23
|
+
const forward = (_event, event) => listener(event)
|
|
24
|
+
ipcRenderer.on("afm:event", forward)
|
|
25
|
+
return Promise.resolve({
|
|
26
|
+
remove: () => {
|
|
27
|
+
ipcRenderer.removeListener("afm:event", forward)
|
|
28
|
+
return Promise.resolve()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.contextIsolated) contextBridge.exposeInMainWorld("afm", afm)
|
|
35
|
+
else globalThis.afm = afm
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The addon's sources, hashed: electron/afm.node is committed (so packages build
|
|
2
|
+
// without Xcode), and electron/afm.node.sha256 records which sources it was built
|
|
3
|
+
// from. build.mjs writes it; a test fails when the sources no longer match, so a
|
|
4
|
+
// change to the Swift or C can't ship with a stale addon.
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
7
|
+
import { dirname, join, relative } from "node:path"
|
|
8
|
+
import { fileURLToPath } from "node:url"
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const root = join(here, "..")
|
|
12
|
+
|
|
13
|
+
/** The files afm.node is built from, in a fixed order. */
|
|
14
|
+
export function sourceFiles() {
|
|
15
|
+
const core = join(root, "ios", "Sources", "AfmCore")
|
|
16
|
+
return [
|
|
17
|
+
...readdirSync(core)
|
|
18
|
+
.filter((file) => file.endsWith(".swift"))
|
|
19
|
+
.sort()
|
|
20
|
+
.map((file) => join(core, file)),
|
|
21
|
+
join(here, "AfmNode.swift"),
|
|
22
|
+
join(here, "afm.c"),
|
|
23
|
+
join(here, "build.mjs")
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A SHA-256 of the sources: each file's path (from the package) and contents. */
|
|
28
|
+
export function sourcesHash() {
|
|
29
|
+
const hash = createHash("sha256")
|
|
30
|
+
for (const file of sourceFiles()) {
|
|
31
|
+
hash.update(relative(root, file))
|
|
32
|
+
hash.update("\0")
|
|
33
|
+
hash.update(readFileSync(file))
|
|
34
|
+
hash.update("\0")
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where the hash of the sources the committed addon was built from is kept. */
|
|
40
|
+
export const hashFile = join(here, "afm.node.sha256")
|
|
@@ -0,0 +1,315 @@
|
|
|
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 }`: 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
|
+
/// - `{ type: "error", code, message }`.
|
|
22
|
+
@available(iOS 26.0, macOS 26.0, *)
|
|
23
|
+
public final class AfmBridge: @unchecked Sendable {
|
|
24
|
+
public typealias Emit = @Sendable ([String: Any]) -> Void
|
|
25
|
+
|
|
26
|
+
private let emit: Emit
|
|
27
|
+
private let lock = NSLock()
|
|
28
|
+
private var sessions: [String: BridgeSession] = [:]
|
|
29
|
+
|
|
30
|
+
public init(emit: @escaping Emit) {
|
|
31
|
+
self.emit = emit
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Whether the model can run here, why not, and the tokens a session holds.
|
|
35
|
+
public static func availability() -> [String: Any] {
|
|
36
|
+
let model = SystemLanguageModel.default
|
|
37
|
+
var result: [String: Any]
|
|
38
|
+
switch model.availability {
|
|
39
|
+
case .available:
|
|
40
|
+
result = ["available": true]
|
|
41
|
+
case .unavailable(let reason):
|
|
42
|
+
let name: String
|
|
43
|
+
switch reason {
|
|
44
|
+
case .deviceNotEligible: name = "deviceNotEligible"
|
|
45
|
+
case .appleIntelligenceNotEnabled: name = "appleIntelligenceNotEnabled"
|
|
46
|
+
case .modelNotReady: name = "modelNotReady"
|
|
47
|
+
@unknown default: name = "unavailable"
|
|
48
|
+
}
|
|
49
|
+
result = ["available": false, "reason": name]
|
|
50
|
+
}
|
|
51
|
+
if #available(iOS 26.4, macOS 26.4, *) {
|
|
52
|
+
result["contextSize"] = model.contextSize
|
|
53
|
+
}
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Starts a session: `sessionId`, `instructions`, `tools` ([{ name, description,
|
|
58
|
+
/// parameters }]) and `messages`, the conversation it starts from. A session with
|
|
59
|
+
/// the same id is ended first.
|
|
60
|
+
public func createSession(_ options: [String: Any]) throws {
|
|
61
|
+
let id = try Self.string(options, "sessionId")
|
|
62
|
+
deleteSession(id)
|
|
63
|
+
let state = BridgeSession(id: id, emit: emit)
|
|
64
|
+
let tools = try (options["tools"] as? [Any] ?? []).map { item -> BridgedTool in
|
|
65
|
+
let spec = try Schema.object(item, "tool")
|
|
66
|
+
return BridgedTool(
|
|
67
|
+
name: spec["name"] as? String ?? "",
|
|
68
|
+
description: spec["description"] as? String ?? "",
|
|
69
|
+
parameters: try Schema.generationSchema(spec["parameters"]),
|
|
70
|
+
state: state
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
let transcript = try Conversation.transcript(
|
|
74
|
+
instructions: options["instructions"] as? String,
|
|
75
|
+
tools: tools.map { Transcript.ToolDefinition(tool: $0) },
|
|
76
|
+
messages: options["messages"] as? [Any] ?? []
|
|
77
|
+
)
|
|
78
|
+
state.session = LanguageModelSession(model: .default, tools: tools, transcript: transcript)
|
|
79
|
+
state.hasTools = !tools.isEmpty
|
|
80
|
+
lock.withLock { sessions[id] = state }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Loads the session's model ahead of its first prompt.
|
|
84
|
+
public func prewarm(_ options: [String: Any]) throws {
|
|
85
|
+
try session(options).session.prewarm()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Prompts a session (`sessionId`, `prompt`, `turn`, `options`: `temperature`,
|
|
89
|
+
/// `maximumResponseTokens`). The response comes as events. With a `schema` (as
|
|
90
|
+
/// tools' parameters take it), the answer is held to it, and `done` has it in JSON.
|
|
91
|
+
public func respond(_ options: [String: Any]) throws {
|
|
92
|
+
let state = try session(options)
|
|
93
|
+
let settings = options["options"] as? [String: Any] ?? [:]
|
|
94
|
+
var generation = GenerationOptions(
|
|
95
|
+
temperature: (settings["temperature"] as? NSNumber)?.doubleValue,
|
|
96
|
+
maximumResponseTokens: (settings["maximumResponseTokens"] as? NSNumber)?.intValue
|
|
97
|
+
)
|
|
98
|
+
// Whether the model may call tools ("allowed"), must ("required") or
|
|
99
|
+
// mustn't ("disallowed"): iOS/macOS 27. Left out, the framework decides.
|
|
100
|
+
if #available(iOS 27.0, macOS 27.0, *), let mode = settings["toolCallingMode"] as? String {
|
|
101
|
+
switch mode {
|
|
102
|
+
case "required": generation.toolCallingMode = .required
|
|
103
|
+
case "disallowed": generation.toolCallingMode = .disallowed
|
|
104
|
+
default: generation.toolCallingMode = .allowed
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let schema = try options["schema"].map(Schema.generationSchema)
|
|
108
|
+
try state.respond(prompt: options["prompt"] as? String ?? "", turn: options["turn"] as? Int ?? 0, options: generation, schema: schema)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A tool's result (`sessionId`, `callId`, `output`): the waiting call returns it.
|
|
112
|
+
public func submitToolResult(_ options: [String: Any]) throws {
|
|
113
|
+
try session(options).resolve(callId: try Self.string(options, "callId"), output: options["output"] as? String ?? "")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Stops a session's response; the session stays.
|
|
117
|
+
public func cancel(_ options: [String: Any]) throws {
|
|
118
|
+
try session(options).cancel()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Ends a session.
|
|
122
|
+
public func deleteSession(_ id: String) {
|
|
123
|
+
lock.withLock { sessions.removeValue(forKey: id) }?.cancel()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func session(_ options: [String: Any]) throws -> BridgeSession {
|
|
127
|
+
let id = try Self.string(options, "sessionId")
|
|
128
|
+
guard let state = lock.withLock({ sessions[id] }) else {
|
|
129
|
+
throw Schema.Unsupported(description: "No session \(id)")
|
|
130
|
+
}
|
|
131
|
+
return state
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private static func string(_ options: [String: Any], _ key: String) throws -> String {
|
|
135
|
+
guard let value = options[key] as? String else { throw Schema.Unsupported(description: "Missing \(key)") }
|
|
136
|
+
return value
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// A session and its response in progress, with the tool calls waiting for results.
|
|
141
|
+
@available(iOS 26.0, macOS 26.0, *)
|
|
142
|
+
final class BridgeSession: @unchecked Sendable {
|
|
143
|
+
let id: String
|
|
144
|
+
var session: LanguageModelSession!
|
|
145
|
+
var hasTools = false
|
|
146
|
+
|
|
147
|
+
private let emit: AfmBridge.Emit
|
|
148
|
+
private let lock = NSLock()
|
|
149
|
+
private var task: Task<Void, Never>?
|
|
150
|
+
private var turn = 0
|
|
151
|
+
private var waiting: [Call] = []
|
|
152
|
+
private var answered: Set<String> = []
|
|
153
|
+
|
|
154
|
+
private struct Call {
|
|
155
|
+
let id: String
|
|
156
|
+
let name: String
|
|
157
|
+
let arguments: String
|
|
158
|
+
var reported: Bool
|
|
159
|
+
let continuation: CheckedContinuation<String, Error>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
init(id: String, emit: @escaping AfmBridge.Emit) {
|
|
163
|
+
self.id = id
|
|
164
|
+
self.emit = emit
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func respond(prompt: String, turn: Int, options: GenerationOptions, schema: GenerationSchema?) throws {
|
|
168
|
+
try lock.withLock {
|
|
169
|
+
guard task == nil else { throw Schema.Unsupported(description: "Session \(id) is responding") }
|
|
170
|
+
self.turn = turn
|
|
171
|
+
answered = []
|
|
172
|
+
task = Task { [self] in await run(prompt, options, schema) }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func run(_ prompt: String, _ options: GenerationOptions, _ schema: GenerationSchema?) async {
|
|
177
|
+
let before = usage()
|
|
178
|
+
var text = ""
|
|
179
|
+
do {
|
|
180
|
+
if let schema {
|
|
181
|
+
text = try await session.respond(to: prompt, schema: schema, options: options).content.jsonString
|
|
182
|
+
} else {
|
|
183
|
+
for try await snapshot in session.streamResponse(to: prompt, options: options) {
|
|
184
|
+
let content = snapshot.content
|
|
185
|
+
let delta = content.hasPrefix(text) ? String(content.dropFirst(text.count)) : content
|
|
186
|
+
text = content
|
|
187
|
+
if !delta.isEmpty { send(["type": "delta", "text": delta]) }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// A stopped response's stream may just end.
|
|
191
|
+
try Task.checkCancellation()
|
|
192
|
+
var done: [String: Any] = ["type": "done", "text": text]
|
|
193
|
+
if let before, let after = usage() {
|
|
194
|
+
var usage: [String: Any] = [
|
|
195
|
+
"inputTokens": after.input - before.input,
|
|
196
|
+
"outputTokens": after.output - before.output
|
|
197
|
+
]
|
|
198
|
+
if !hasTools { usage["cachedTokens"] = after.cached - before.cached }
|
|
199
|
+
done["usage"] = usage
|
|
200
|
+
}
|
|
201
|
+
send(done)
|
|
202
|
+
} catch is CancellationError {
|
|
203
|
+
send(["type": "error", "code": "cancelled", "message": "The response was stopped"])
|
|
204
|
+
} catch {
|
|
205
|
+
send(["type": "error", "code": Self.code(error), "message": String(describing: error)])
|
|
206
|
+
}
|
|
207
|
+
lock.withLock { task = nil }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// A tool call: waits for its result, reporting the model's batch once it's whole.
|
|
211
|
+
func call(name: String, arguments: GeneratedContent) async throws -> String {
|
|
212
|
+
let json = arguments.jsonString
|
|
213
|
+
return try await withTaskCancellationHandler {
|
|
214
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
215
|
+
lock.withLock {
|
|
216
|
+
// Cancelled before this registers: the cancellation handler has
|
|
217
|
+
// already run (under this lock), so it would never see this call.
|
|
218
|
+
if Task.isCancelled {
|
|
219
|
+
continuation.resume(throwing: CancellationError())
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
// The model's batch of calls, from the transcript: its ids, and its size.
|
|
223
|
+
var batch: [Transcript.ToolCall] = []
|
|
224
|
+
if case .toolCalls(let calls) = session.transcript.last { batch = Array(calls) }
|
|
225
|
+
let taken = Set(waiting.map(\.id)).union(answered)
|
|
226
|
+
let match = batch.first { !taken.contains($0.id) && $0.toolName == name && $0.arguments.jsonString == json }
|
|
227
|
+
?? batch.first { !taken.contains($0.id) && $0.toolName == name }
|
|
228
|
+
waiting.append(Call(id: match?.id ?? UUID().uuidString, name: name, arguments: json, reported: false, continuation: continuation))
|
|
229
|
+
// Every call of the batch has started (or the batch can't be read).
|
|
230
|
+
let started = Set(waiting.map(\.id)).union(answered)
|
|
231
|
+
if match == nil || batch.allSatisfy({ started.contains($0.id) }) { reportWaiting() }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} onCancel: {
|
|
235
|
+
self.failWaiting(CancellationError())
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Sends the waiting calls not sent yet. Called holding the lock.
|
|
240
|
+
private func reportWaiting() {
|
|
241
|
+
let calls = waiting.indices.filter { !waiting[$0].reported }
|
|
242
|
+
guard !calls.isEmpty else { return }
|
|
243
|
+
for index in calls { waiting[index].reported = true }
|
|
244
|
+
send(["type": "toolCalls", "calls": calls.map { ["id": waiting[$0].id, "name": waiting[$0].name, "arguments": waiting[$0].arguments] }])
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func resolve(callId: String, output: String) throws {
|
|
248
|
+
let continuation = try lock.withLock { () -> CheckedContinuation<String, Error> in
|
|
249
|
+
guard let index = waiting.firstIndex(where: { $0.id == callId }) else {
|
|
250
|
+
throw Schema.Unsupported(description: "No tool call \(callId) is waiting")
|
|
251
|
+
}
|
|
252
|
+
answered.insert(callId)
|
|
253
|
+
return waiting.remove(at: index).continuation
|
|
254
|
+
}
|
|
255
|
+
continuation.resume(returning: output)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func cancel() {
|
|
259
|
+
let task = lock.withLock { self.task }
|
|
260
|
+
task?.cancel()
|
|
261
|
+
failWaiting(CancellationError())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func failWaiting(_ error: Error) {
|
|
265
|
+
let calls = lock.withLock { () -> [Call] in
|
|
266
|
+
defer { waiting = [] }
|
|
267
|
+
return waiting
|
|
268
|
+
}
|
|
269
|
+
for call in calls { call.continuation.resume(throwing: error) }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func send(_ event: [String: Any]) {
|
|
273
|
+
var event = event
|
|
274
|
+
event["sessionId"] = id
|
|
275
|
+
event["turn"] = turn
|
|
276
|
+
emit(event)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// The session's tokens so far (iOS/macOS 27), over every pass of the model:
|
|
280
|
+
/// `respond`'s usage is the difference, a tool call's pass included, which the
|
|
281
|
+
/// response's own usage leaves out.
|
|
282
|
+
///
|
|
283
|
+
/// The cached count is right for a session without tools: 0 for a cold
|
|
284
|
+
/// prompt, the new message's tokens fewer for a follow-up, and 0 again once
|
|
285
|
+
/// the framework has dropped the cache (a few seconds idle). With tools, the
|
|
286
|
+
/// framework counts every token but one as cached, whether the cache was
|
|
287
|
+
/// used (TTFT 1.5 s) or had been dropped (5.9 s), so it isn't reported.
|
|
288
|
+
private func usage() -> (input: Int, cached: Int, output: Int)? {
|
|
289
|
+
guard #available(iOS 27.0, macOS 27.0, *) else { return nil }
|
|
290
|
+
let usage = session.usage
|
|
291
|
+
return (usage.input.totalTokenCount, usage.input.cachedTokenCount, usage.output.totalTokenCount)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// An error's case name (`exceededContextWindowSize`, `guardrailViolation`, …).
|
|
295
|
+
static func code(_ error: Error) -> String {
|
|
296
|
+
let text = String(describing: error)
|
|
297
|
+
return String(text.prefix { $0 != "(" && $0 != ":" && $0 != " " })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// A tool the JavaScript side runs, as the framework sees it.
|
|
302
|
+
@available(iOS 26.0, macOS 26.0, *)
|
|
303
|
+
struct BridgedTool: Tool {
|
|
304
|
+
let name: String
|
|
305
|
+
let description: String
|
|
306
|
+
let parameters: GenerationSchema
|
|
307
|
+
/// Weak: the session's `LanguageModelSession` holds its tools, so a strong
|
|
308
|
+
/// reference back would keep a deleted session alive.
|
|
309
|
+
weak let state: BridgeSession?
|
|
310
|
+
|
|
311
|
+
func call(arguments: GeneratedContent) async throws -> String {
|
|
312
|
+
guard let state else { throw CancellationError() }
|
|
313
|
+
return try await state.call(name: name, arguments: arguments)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -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())
|
|
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
|
+
}
|