@swiftbrowser/native 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -25,8 +25,22 @@ public final class JavaScriptCoreHost: JSHost {
|
|
|
25
25
|
installExceptionHandler()
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/// JavaScriptCore keeps a process-wide registry of its virtual machines
|
|
29
|
+
/// under a lock of its own, and making one on a thread while another is
|
|
30
|
+
/// being made or dropped elsewhere has crashed in that lock (`VM::VM` →
|
|
31
|
+
/// `unlockSlow`, "Invalid value for lock", the iOS 26.2 simulator under
|
|
32
|
+
/// the test suites running in parallel, each with contexts and workers
|
|
33
|
+
/// of its own). So every virtual machine this package makes or drops is
|
|
34
|
+
/// made or dropped one at a time, under this lock: the app's context
|
|
35
|
+
/// here and each worker's (WorkerHost.swift).
|
|
36
|
+
nonisolated private static let virtualMachineLock = NSLock()
|
|
37
|
+
|
|
38
|
+
nonisolated static func withVirtualMachineLock<T>(_ body: () throws -> T) rethrows -> T {
|
|
39
|
+
try virtualMachineLock.withLock(body)
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
private static func makeContext() -> (JSContext, JSValue) {
|
|
29
|
-
let context = JSContext()!
|
|
43
|
+
let context = withVirtualMachineLock { JSContext()! }
|
|
30
44
|
context.name = "SwiftBrowser"
|
|
31
45
|
let native = JSValue(newObjectIn: context)!
|
|
32
46
|
context.setObject(native, forKeyedSubscript: "__sbNative" as NSString)
|
|
@@ -46,8 +60,10 @@ public final class JavaScriptCoreHost: JSHost {
|
|
|
46
60
|
|
|
47
61
|
public func reset() {
|
|
48
62
|
context.exceptionHandler = nil
|
|
49
|
-
(context, native) = JavaScriptCoreHost.makeContext()
|
|
50
63
|
pendingError = nil
|
|
64
|
+
let fresh = JavaScriptCoreHost.makeContext()
|
|
65
|
+
// The old context (and its virtual machine) goes under the lock too.
|
|
66
|
+
JavaScriptCoreHost.withVirtualMachineLock { (context, native) = fresh }
|
|
51
67
|
installExceptionHandler()
|
|
52
68
|
}
|
|
53
69
|
|
|
@@ -84,6 +100,42 @@ public final class JavaScriptCoreHost: JSHost {
|
|
|
84
100
|
return result.map(JavaScriptCoreHost.json(from:)) ?? .null
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
public var carriesBytes: Bool { true }
|
|
104
|
+
|
|
105
|
+
public func installBytes(_ name: String, _ body: @escaping ([HostValue]) -> HostValue?) {
|
|
106
|
+
let block: @convention(block) () -> Any? = {
|
|
107
|
+
let arguments = (JSContext.currentArguments() as? [JSValue]) ?? []
|
|
108
|
+
switch body(arguments.map(JavaScriptCoreHost.hostValue(from:))) {
|
|
109
|
+
case .json(let json)?: return json.anyObject
|
|
110
|
+
case .bytes(let bytes)?:
|
|
111
|
+
guard let context = JSContext.current() else { return nil }
|
|
112
|
+
return JavaScriptCoreHost.typedArray(bytes, in: context)
|
|
113
|
+
case nil: return nil
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
native.setObject(unsafeBitCast(block, to: AnyObject.self), forKeyedSubscript: name as NSString)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@discardableResult
|
|
120
|
+
public func call(_ function: String, values: [HostValue]) throws -> JSONValue {
|
|
121
|
+
guard let callee = context.objectForKeyedSubscript(function), !callee.isUndefined else {
|
|
122
|
+
throw JSError(message: "\(function) is not defined")
|
|
123
|
+
}
|
|
124
|
+
pendingError = nil
|
|
125
|
+
let arguments: [Any] = values.map { value in
|
|
126
|
+
switch value {
|
|
127
|
+
case .json(let json): return json.anyObject
|
|
128
|
+
case .bytes(let bytes): return JavaScriptCoreHost.typedArray(bytes, in: context)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let result = callee.call(withArguments: arguments)
|
|
132
|
+
if let error = pendingError {
|
|
133
|
+
pendingError = nil
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
return result.map(JavaScriptCoreHost.json(from:)) ?? .null
|
|
137
|
+
}
|
|
138
|
+
|
|
87
139
|
public func hasGlobal(_ name: String) -> Bool {
|
|
88
140
|
guard let value = context.objectForKeyedSubscript(name) else { return false }
|
|
89
141
|
return !value.isUndefined
|
|
@@ -99,5 +151,92 @@ public final class JavaScriptCoreHost: JSHost {
|
|
|
99
151
|
if value.isNumber { return .number(value.toDouble()) }
|
|
100
152
|
return JSONValue(any: value.toObject())
|
|
101
153
|
}
|
|
154
|
+
|
|
155
|
+
// MARK: Bytes
|
|
156
|
+
|
|
157
|
+
/// A JavaScript value as a `HostValue`: a typed array's or an
|
|
158
|
+
/// `ArrayBuffer`'s bytes copied out (docs/notes/bytes.md), else JSON. The
|
|
159
|
+
/// typed-array check comes first: `toObject` would turn a view into a
|
|
160
|
+
/// dictionary of its indices, slowly. The pointer the engine gives is
|
|
161
|
+
/// read inside this call, while no JavaScript runs.
|
|
162
|
+
nonisolated static func hostValue(from value: JSValue) -> HostValue {
|
|
163
|
+
guard !value.isUndefined, !value.isNull, value.isObject, let context = value.context?.jsGlobalContextRef else { return .json(json(from: value)) }
|
|
164
|
+
var exception: JSValueRef?
|
|
165
|
+
let type = JSValueGetTypedArrayType(context, value.jsValueRef, &exception)
|
|
166
|
+
guard type != kJSTypedArrayTypeNone, let kind = bytesKind(type), let object = JSValueToObject(context, value.jsValueRef, &exception) else {
|
|
167
|
+
return .json(json(from: value))
|
|
168
|
+
}
|
|
169
|
+
if kind == .arrayBuffer {
|
|
170
|
+
let length = JSObjectGetArrayBufferByteLength(context, object, &exception)
|
|
171
|
+
guard length > 0, let pointer = JSObjectGetArrayBufferBytesPtr(context, object, &exception) else { return .bytes(HostBytes(Data(), kind: kind)) }
|
|
172
|
+
return .bytes(HostBytes(Data(bytes: pointer, count: length), kind: kind))
|
|
173
|
+
}
|
|
174
|
+
// The pointer is the backing buffer's start; the view begins at its offset.
|
|
175
|
+
let length = JSObjectGetTypedArrayByteLength(context, object, &exception)
|
|
176
|
+
let offset = JSObjectGetTypedArrayByteOffset(context, object, &exception)
|
|
177
|
+
guard length > 0, let pointer = JSObjectGetTypedArrayBytesPtr(context, object, &exception) else { return .bytes(HostBytes(Data(), kind: kind)) }
|
|
178
|
+
return .bytes(HostBytes(Data(bytes: pointer + offset, count: length), kind: kind))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// A typed array (or `ArrayBuffer`) the engine allocates, the bytes copied in.
|
|
182
|
+
nonisolated static func typedArray(_ bytes: HostBytes, in context: JSContext) -> JSValue {
|
|
183
|
+
let global = context.jsGlobalContextRef
|
|
184
|
+
let size = elementSize(bytes.kind)
|
|
185
|
+
let count = bytes.data.count / size
|
|
186
|
+
var exception: JSValueRef?
|
|
187
|
+
let type = bytes.kind == .arrayBuffer ? kJSTypedArrayTypeUint8Array : typedArrayType(bytes.kind)
|
|
188
|
+
guard let array = JSObjectMakeTypedArray(global, type, count, &exception) else { return JSValue(nullIn: context) }
|
|
189
|
+
if count > 0, let pointer = JSObjectGetTypedArrayBytesPtr(global, array, &exception) {
|
|
190
|
+
bytes.data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: count * size)
|
|
191
|
+
}
|
|
192
|
+
if bytes.kind == .arrayBuffer, let buffer = JSObjectGetTypedArrayBuffer(global, array, &exception) {
|
|
193
|
+
return JSValue(jsValueRef: buffer, in: context)
|
|
194
|
+
}
|
|
195
|
+
return JSValue(jsValueRef: array, in: context)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private nonisolated static func elementSize(_ kind: BytesKind) -> Int {
|
|
199
|
+
switch kind {
|
|
200
|
+
case .int8, .uint8, .uint8Clamped, .arrayBuffer: return 1
|
|
201
|
+
case .int16, .uint16: return 2
|
|
202
|
+
case .int32, .uint32, .float32: return 4
|
|
203
|
+
case .float64, .bigInt64, .bigUint64: return 8
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private nonisolated static func bytesKind(_ type: JSTypedArrayType) -> BytesKind? {
|
|
208
|
+
switch type {
|
|
209
|
+
case kJSTypedArrayTypeInt8Array: return .int8
|
|
210
|
+
case kJSTypedArrayTypeUint8Array: return .uint8
|
|
211
|
+
case kJSTypedArrayTypeUint8ClampedArray: return .uint8Clamped
|
|
212
|
+
case kJSTypedArrayTypeInt16Array: return .int16
|
|
213
|
+
case kJSTypedArrayTypeUint16Array: return .uint16
|
|
214
|
+
case kJSTypedArrayTypeInt32Array: return .int32
|
|
215
|
+
case kJSTypedArrayTypeUint32Array: return .uint32
|
|
216
|
+
case kJSTypedArrayTypeFloat32Array: return .float32
|
|
217
|
+
case kJSTypedArrayTypeFloat64Array: return .float64
|
|
218
|
+
case kJSTypedArrayTypeBigInt64Array: return .bigInt64
|
|
219
|
+
case kJSTypedArrayTypeBigUint64Array: return .bigUint64
|
|
220
|
+
case kJSTypedArrayTypeArrayBuffer: return .arrayBuffer
|
|
221
|
+
default: return nil
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private nonisolated static func typedArrayType(_ kind: BytesKind) -> JSTypedArrayType {
|
|
226
|
+
switch kind {
|
|
227
|
+
case .int8: return kJSTypedArrayTypeInt8Array
|
|
228
|
+
case .uint8: return kJSTypedArrayTypeUint8Array
|
|
229
|
+
case .uint8Clamped: return kJSTypedArrayTypeUint8ClampedArray
|
|
230
|
+
case .int16: return kJSTypedArrayTypeInt16Array
|
|
231
|
+
case .uint16: return kJSTypedArrayTypeUint16Array
|
|
232
|
+
case .int32: return kJSTypedArrayTypeInt32Array
|
|
233
|
+
case .uint32: return kJSTypedArrayTypeUint32Array
|
|
234
|
+
case .float32: return kJSTypedArrayTypeFloat32Array
|
|
235
|
+
case .float64: return kJSTypedArrayTypeFloat64Array
|
|
236
|
+
case .bigInt64: return kJSTypedArrayTypeBigInt64Array
|
|
237
|
+
case .bigUint64: return kJSTypedArrayTypeBigUint64Array
|
|
238
|
+
case .arrayBuffer: return kJSTypedArrayTypeArrayBuffer
|
|
239
|
+
}
|
|
240
|
+
}
|
|
102
241
|
}
|
|
103
242
|
#endif
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && !canImport(FoundationBridge)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
#if canImport(FoundationModels)
|
|
5
|
+
import FoundationModels
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// Foundation Models for a JavaScript app (`LanguageModelSession` in
|
|
9
|
+
/// @swiftbrowser/react; docs/ops-protocol.md, "The on-device language
|
|
10
|
+
/// model"): `languageModel` requests, answered by Apple Intelligence's
|
|
11
|
+
/// on-device model where it can answer (iOS 26 and macOS 26), else by the
|
|
12
|
+
/// app's `LanguageModels.fallback` (LanguageModelProvider.swift), else not at
|
|
13
|
+
/// all. The commands are the page's (Web/src/languageModel.ts):
|
|
14
|
+
///
|
|
15
|
+
/// - `availability` → `{ status: "available" }` or `{ status: "unavailable",
|
|
16
|
+
/// reason }`, the model that would answer, with `download: { size?,
|
|
17
|
+
/// downloaded, progress?, failure? }` where it is a model the app fetches;
|
|
18
|
+
/// and `models`, the choices (Apple Intelligence where it answers, then the
|
|
19
|
+
/// fallback's), each `{ id, name, summary?, selected, download? }`;
|
|
20
|
+
/// - `select` makes the choice `id` answer from the next session on
|
|
21
|
+
/// (remembered across launches);
|
|
22
|
+
/// - `download` starts fetching the app's model, `deleteDownload` removes it;
|
|
23
|
+
/// - `open` makes the app's session `session` with its `instructions` and
|
|
24
|
+
/// `tools` (`[{ name, description, parameters }]`, the arguments a JSON
|
|
25
|
+
/// schema) on that model, `prewarm` loads it, `close` lets it go;
|
|
26
|
+
/// - `toolCall` answers the session's next call of one of its tools, once
|
|
27
|
+
/// the model makes one, as `{ call, name, arguments }` (`{ closed: true }`
|
|
28
|
+
/// once the session is closed); the app runs it and answers with
|
|
29
|
+
/// `toolResult` (`call`, and `output` or `error`), which the model reads;
|
|
30
|
+
/// - `respond` → `{ content }`, the whole response;
|
|
31
|
+
/// - `stream` starts a response as stream `stream`; each `next` answers
|
|
32
|
+
/// `{ content, done }` once the response has grown or ended, the latest
|
|
33
|
+
/// snapshot (a reader slower than the model skips what it missed);
|
|
34
|
+
/// `cancel` stops it.
|
|
35
|
+
///
|
|
36
|
+
/// A failure rejects as a tagged message (`context: …`, `guardrail: …`),
|
|
37
|
+
/// which the app reads back as its `GenerationError` case.
|
|
38
|
+
@MainActor
|
|
39
|
+
final class LanguageModelBridge {
|
|
40
|
+
/// A response under way, read by `next`.
|
|
41
|
+
@MainActor
|
|
42
|
+
private final class Stream {
|
|
43
|
+
var content = ""
|
|
44
|
+
var done = false
|
|
45
|
+
var error: Error?
|
|
46
|
+
/// Whether the app has had the content as it is now.
|
|
47
|
+
var delivered = true
|
|
48
|
+
var waiter: CheckedContinuation<Void, Never>?
|
|
49
|
+
var task: Task<Void, Never>?
|
|
50
|
+
|
|
51
|
+
func wake() {
|
|
52
|
+
waiter?.resume()
|
|
53
|
+
waiter = nil
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private var sessions: [Int: any LanguageModelProviderSession] = [:]
|
|
58
|
+
/// Each session's tool calls, waiting for the app.
|
|
59
|
+
private var relays: [Int: ToolRelay] = [:]
|
|
60
|
+
private var streams: [Int: Stream] = [:]
|
|
61
|
+
/// Apple Intelligence's model, once asked for (iOS 26 and macOS 26).
|
|
62
|
+
private var system: (any LanguageModelProvider)?
|
|
63
|
+
/// The viewer's choice: `system`, `fallback`, or nil for the default
|
|
64
|
+
/// (Apple Intelligence where it answers, else the app's model).
|
|
65
|
+
private static let choiceKey = "SwiftBrowserNative.languageModelChoice"
|
|
66
|
+
static let systemChoice = "system"
|
|
67
|
+
|
|
68
|
+
func handle(_ params: JSONObject) async throws -> JSONValue {
|
|
69
|
+
switch params["action"]?.string {
|
|
70
|
+
case "availability":
|
|
71
|
+
let current = await choice()
|
|
72
|
+
var answer = Self.json(current.availability, download: current.provider?.downloadState)
|
|
73
|
+
let models = await modelsJSON(current)
|
|
74
|
+
if !models.isEmpty, case .object(var object) = answer {
|
|
75
|
+
object["models"] = .array(models)
|
|
76
|
+
answer = .object(object)
|
|
77
|
+
}
|
|
78
|
+
return answer
|
|
79
|
+
case "select":
|
|
80
|
+
guard let id = params["id"]?.string else { throw HostRequestFailure("languageModel: no id") }
|
|
81
|
+
if id == Self.systemChoice {
|
|
82
|
+
UserDefaults.standard.set(Self.systemChoice, forKey: Self.choiceKey)
|
|
83
|
+
} else {
|
|
84
|
+
UserDefaults.standard.set("fallback", forKey: Self.choiceKey)
|
|
85
|
+
LanguageModels.fallback?.select(id)
|
|
86
|
+
}
|
|
87
|
+
return .object([:])
|
|
88
|
+
case "download":
|
|
89
|
+
LanguageModels.fallback?.startDownload()
|
|
90
|
+
return .object([:])
|
|
91
|
+
case "deleteDownload":
|
|
92
|
+
try await LanguageModels.fallback?.deleteDownload()
|
|
93
|
+
return .object([:])
|
|
94
|
+
case "open":
|
|
95
|
+
let id = try Self.int(params, "session")
|
|
96
|
+
let current = await choice()
|
|
97
|
+
guard let provider = current.provider, current.availability == .available else {
|
|
98
|
+
throw LanguageModelError(.unavailable, "the on-device language model is not available")
|
|
99
|
+
}
|
|
100
|
+
let relay = ToolRelay()
|
|
101
|
+
let tools = (params["tools"]?.array ?? []).compactMap { tool -> LanguageModelTool? in
|
|
102
|
+
guard let object = tool.object, let name = object["name"]?.string else { return nil }
|
|
103
|
+
return LanguageModelTool(
|
|
104
|
+
name: name,
|
|
105
|
+
description: object["description"]?.string ?? "",
|
|
106
|
+
parameters: object["parameters"] ?? .object(["type": .string("object"), "properties": .object([:])]),
|
|
107
|
+
call: { arguments in try await relay.call(name: name, arguments: arguments) }
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
sessions[id] = try provider.makeSession(instructions: params["instructions"]?.string, tools: tools)
|
|
111
|
+
relays[id] = relay
|
|
112
|
+
return .object([:])
|
|
113
|
+
case "toolCall":
|
|
114
|
+
let id = try Self.int(params, "session")
|
|
115
|
+
guard let relay = relays[id] else { return .object(["closed": .bool(true)]) }
|
|
116
|
+
return await relay.next()
|
|
117
|
+
case "toolResult":
|
|
118
|
+
let id = try Self.int(params, "session")
|
|
119
|
+
relays[id]?.answer(try Self.int(params, "call"), output: params["output"]?.string, error: params["error"]?.string)
|
|
120
|
+
return .object([:])
|
|
121
|
+
case "prewarm":
|
|
122
|
+
try session(params).prewarm()
|
|
123
|
+
return .object([:])
|
|
124
|
+
case "respond":
|
|
125
|
+
let response = try session(params).streamResponse(to: params["prompt"]?.string ?? "", options: Self.options(params["options"]))
|
|
126
|
+
var content = ""
|
|
127
|
+
for try await snapshot in response {
|
|
128
|
+
content = snapshot
|
|
129
|
+
}
|
|
130
|
+
return .object(["content": .string(content)])
|
|
131
|
+
case "stream":
|
|
132
|
+
let response = try session(params).streamResponse(to: params["prompt"]?.string ?? "", options: Self.options(params["options"]))
|
|
133
|
+
stream(response, id: try Self.int(params, "stream"))
|
|
134
|
+
return .object([:])
|
|
135
|
+
case "next":
|
|
136
|
+
return try await next(Self.int(params, "stream"))
|
|
137
|
+
case "cancel":
|
|
138
|
+
if let stream = try streams[Self.int(params, "stream")], !stream.done {
|
|
139
|
+
stream.task?.cancel()
|
|
140
|
+
finish(stream)
|
|
141
|
+
}
|
|
142
|
+
return .object([:])
|
|
143
|
+
case "close":
|
|
144
|
+
let id = try Self.int(params, "session")
|
|
145
|
+
sessions.removeValue(forKey: id)?.close()
|
|
146
|
+
relays.removeValue(forKey: id)?.close()
|
|
147
|
+
return .object([:])
|
|
148
|
+
case let action:
|
|
149
|
+
throw HostRequestFailure("unsupported: languageModel action \(action ?? "none")")
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Cancels every response and lets every session go (the app is going away).
|
|
154
|
+
func stop() {
|
|
155
|
+
for stream in streams.values {
|
|
156
|
+
stream.task?.cancel()
|
|
157
|
+
finish(stream)
|
|
158
|
+
}
|
|
159
|
+
streams = [:]
|
|
160
|
+
for session in sessions.values { session.close() }
|
|
161
|
+
sessions = [:]
|
|
162
|
+
for relay in relays.values { relay.close() }
|
|
163
|
+
relays = [:]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The model that answers now and its availability: Apple Intelligence's
|
|
167
|
+
/// where it can answer and the viewer has not chosen the app's model;
|
|
168
|
+
/// otherwise the app's fallback, unless the system model is only still
|
|
169
|
+
/// downloading and the viewer has not chosen; otherwise none, for the
|
|
170
|
+
/// system's reason.
|
|
171
|
+
private func choice() async -> Choice {
|
|
172
|
+
var reason = LanguageModelAvailability.Reason.deviceNotEligible
|
|
173
|
+
let chosen = UserDefaults.standard.string(forKey: Self.choiceKey)
|
|
174
|
+
var systemAnswers = false
|
|
175
|
+
#if canImport(FoundationModels)
|
|
176
|
+
if #available(iOS 26.0, macOS 26.0, *) {
|
|
177
|
+
let provider = system ?? FoundationModelsProvider()
|
|
178
|
+
system = provider
|
|
179
|
+
let availability = await provider.availability()
|
|
180
|
+
systemAnswers = availability == .available
|
|
181
|
+
if systemAnswers, chosen != "fallback" || LanguageModels.fallback == nil {
|
|
182
|
+
return Choice(provider: provider, availability: availability, systemAnswers: true, isFallback: false)
|
|
183
|
+
}
|
|
184
|
+
if case .unavailable(let systemReason) = availability { reason = systemReason }
|
|
185
|
+
}
|
|
186
|
+
#endif
|
|
187
|
+
if let fallback = LanguageModels.fallback, systemAnswers || reason != .modelNotReady || chosen == "fallback" {
|
|
188
|
+
return Choice(provider: fallback, availability: await fallback.availability(), systemAnswers: systemAnswers, isFallback: true)
|
|
189
|
+
}
|
|
190
|
+
return Choice(provider: nil, availability: .unavailable(reason), systemAnswers: systemAnswers, isFallback: false)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private struct Choice {
|
|
194
|
+
let provider: (any LanguageModelProvider)?
|
|
195
|
+
let availability: LanguageModelAvailability
|
|
196
|
+
/// Whether Apple Intelligence can answer, chosen or not.
|
|
197
|
+
let systemAnswers: Bool
|
|
198
|
+
let isFallback: Bool
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// The models the viewer can choose: Apple Intelligence where it answers,
|
|
202
|
+
/// then the fallback's; none when there is no choice to make.
|
|
203
|
+
private func modelsJSON(_ current: Choice) async -> [JSONValue] {
|
|
204
|
+
var models: [JSONValue] = []
|
|
205
|
+
if current.systemAnswers {
|
|
206
|
+
models.append(.object([
|
|
207
|
+
"id": .string(Self.systemChoice),
|
|
208
|
+
"name": .string("Apple Intelligence"),
|
|
209
|
+
"summary": .string("Built into this device"),
|
|
210
|
+
"selected": .bool(!current.isFallback),
|
|
211
|
+
]))
|
|
212
|
+
}
|
|
213
|
+
if let fallback = LanguageModels.fallback {
|
|
214
|
+
let choices = fallback.choices
|
|
215
|
+
for choice in choices {
|
|
216
|
+
var object: JSONObject = [
|
|
217
|
+
"id": .string(choice.id),
|
|
218
|
+
"name": .string(choice.name),
|
|
219
|
+
"selected": .bool(current.isFallback && choice.id == fallback.selectedChoice),
|
|
220
|
+
]
|
|
221
|
+
if let summary = choice.summary { object["summary"] = .string(summary) }
|
|
222
|
+
if let download = choice.download { object["download"] = Self.json(download) }
|
|
223
|
+
models.append(.object(object))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return models.count > 1 ? models : []
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private func session(_ params: JSONObject) throws -> any LanguageModelProviderSession {
|
|
230
|
+
let id = try Self.int(params, "session")
|
|
231
|
+
guard let session = sessions[id] else { throw HostRequestFailure("languageModel: no session \(id)") }
|
|
232
|
+
return session
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private func stream(_ response: AsyncThrowingStream<String, Error>, id: Int) {
|
|
236
|
+
let stream = Stream()
|
|
237
|
+
streams[id] = stream
|
|
238
|
+
stream.task = Task { @MainActor [weak self] in
|
|
239
|
+
do {
|
|
240
|
+
for try await snapshot in response {
|
|
241
|
+
if stream.done { break }
|
|
242
|
+
stream.content = snapshot
|
|
243
|
+
stream.delivered = false
|
|
244
|
+
stream.wake()
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
if !Task.isCancelled { stream.error = error }
|
|
248
|
+
}
|
|
249
|
+
self?.finish(stream)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private func finish(_ stream: Stream) {
|
|
254
|
+
guard !stream.done else { return }
|
|
255
|
+
stream.done = true
|
|
256
|
+
stream.delivered = false
|
|
257
|
+
stream.wake()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private func next(_ id: Int) async throws -> JSONValue {
|
|
261
|
+
guard let stream = streams[id] else { throw HostRequestFailure("languageModel: no stream \(id)") }
|
|
262
|
+
while stream.delivered {
|
|
263
|
+
await withCheckedContinuation { stream.waiter = $0 }
|
|
264
|
+
}
|
|
265
|
+
stream.delivered = true
|
|
266
|
+
if stream.done { streams[id] = nil }
|
|
267
|
+
if let error = stream.error { throw error }
|
|
268
|
+
return .object(["content": .string(stream.content), "done": .bool(stream.done)])
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// MARK: Conversions
|
|
272
|
+
|
|
273
|
+
static func json(_ availability: LanguageModelAvailability, download: LanguageModelDownload? = nil) -> JSONValue {
|
|
274
|
+
var object: JSONObject
|
|
275
|
+
switch availability {
|
|
276
|
+
case .available:
|
|
277
|
+
object = ["status": .string("available")]
|
|
278
|
+
case .unavailable(let reason):
|
|
279
|
+
object = ["status": .string("unavailable"), "reason": .string(reason.rawValue)]
|
|
280
|
+
}
|
|
281
|
+
if let download { object["download"] = json(download) }
|
|
282
|
+
return .object(object)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
static func json(_ download: LanguageModelDownload) -> JSONValue {
|
|
286
|
+
var state: JSONObject = ["downloaded": .bool(download.isDownloaded)]
|
|
287
|
+
if let size = download.size { state["size"] = .number(Double(size)) }
|
|
288
|
+
if let progress = download.progress { state["progress"] = .number(progress) }
|
|
289
|
+
if let failure = download.failure { state["failure"] = .string(failure) }
|
|
290
|
+
return .object(state)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
static func options(_ value: JSONValue?) -> LanguageModelOptions {
|
|
294
|
+
let options = value?.object ?? [:]
|
|
295
|
+
return LanguageModelOptions(
|
|
296
|
+
temperature: options["temperature"]?.double,
|
|
297
|
+
maximumResponseTokens: options["maximumResponseTokens"]?.int,
|
|
298
|
+
greedy: options["sampling"]?.string == "greedy"
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private static func int(_ params: JSONObject, _ key: String) throws -> Int {
|
|
303
|
+
guard let value = params[key]?.int else { throw HostRequestFailure("languageModel: no \(key)") }
|
|
304
|
+
return value
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// A session's tool calls on their way to the app and back: the model's
|
|
309
|
+
/// call waits here until the app, asking with `toolCall`, has run it and
|
|
310
|
+
/// answered with `toolResult`.
|
|
311
|
+
@MainActor
|
|
312
|
+
final class ToolRelay {
|
|
313
|
+
private struct Call {
|
|
314
|
+
let id: Int
|
|
315
|
+
let name: String
|
|
316
|
+
let arguments: JSONValue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private var waiting: [Call] = []
|
|
320
|
+
private var answers: [Int: CheckedContinuation<String, Error>] = [:]
|
|
321
|
+
/// The app's `toolCall` request, when it asked before the model called.
|
|
322
|
+
private var asker: CheckedContinuation<JSONValue, Never>?
|
|
323
|
+
private var nextID = 1
|
|
324
|
+
private var closed = false
|
|
325
|
+
|
|
326
|
+
/// Hands the call to the app and waits for what it answers.
|
|
327
|
+
func call(name: String, arguments: JSONValue) async throws -> String {
|
|
328
|
+
guard !closed else { throw LanguageModelError(.unavailable, "the session is closed") }
|
|
329
|
+
let id = nextID
|
|
330
|
+
nextID += 1
|
|
331
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
332
|
+
answers[id] = continuation
|
|
333
|
+
let call = Call(id: id, name: name, arguments: arguments)
|
|
334
|
+
if let asker {
|
|
335
|
+
self.asker = nil
|
|
336
|
+
asker.resume(returning: Self.json(call))
|
|
337
|
+
} else {
|
|
338
|
+
waiting.append(call)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// The next call for the app to run, once the model makes one.
|
|
344
|
+
func next() async -> JSONValue {
|
|
345
|
+
if !waiting.isEmpty { return Self.json(waiting.removeFirst()) }
|
|
346
|
+
if closed { return .object(["closed": .bool(true)]) }
|
|
347
|
+
return await withCheckedContinuation { asker = $0 }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func answer(_ id: Int, output: String?, error: String?) {
|
|
351
|
+
guard let continuation = answers.removeValue(forKey: id) else { return }
|
|
352
|
+
if let error {
|
|
353
|
+
continuation.resume(throwing: LanguageModelError(.unavailable, "the tool failed: \(error)"))
|
|
354
|
+
} else {
|
|
355
|
+
continuation.resume(returning: output ?? "")
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// The session is gone: the app stops asking, and calls under way fail.
|
|
360
|
+
func close() {
|
|
361
|
+
closed = true
|
|
362
|
+
asker?.resume(returning: .object(["closed": .bool(true)]))
|
|
363
|
+
asker = nil
|
|
364
|
+
for continuation in answers.values { continuation.resume(throwing: LanguageModelError(.unavailable, "the session is closed")) }
|
|
365
|
+
answers = [:]
|
|
366
|
+
waiting = []
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private static func json(_ call: Call) -> JSONValue {
|
|
370
|
+
.object(["call": .number(Double(call.id)), "name": .string(call.name), "arguments": call.arguments])
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#if canImport(FoundationModels)
|
|
375
|
+
/// Apple Intelligence's on-device model as a provider: FoundationModels'
|
|
376
|
+
/// `SystemLanguageModel.default` and `LanguageModelSession`.
|
|
377
|
+
@available(iOS 26.0, macOS 26.0, *)
|
|
378
|
+
@MainActor
|
|
379
|
+
final class FoundationModelsProvider: LanguageModelProvider {
|
|
380
|
+
func availability() async -> LanguageModelAvailability {
|
|
381
|
+
// Off the main thread: the first read can take a while (seconds in the Simulator).
|
|
382
|
+
Self.availability(await Task.detached { SystemLanguageModel.default.availability }.value)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
func makeSession(instructions: String?, tools: [LanguageModelTool]) throws -> any LanguageModelProviderSession {
|
|
386
|
+
let bridged: [any Tool] = try tools.map { try BridgedTool($0) }
|
|
387
|
+
return Session(instructions.map { LanguageModelSession(tools: bridged, instructions: $0) } ?? LanguageModelSession(tools: bridged))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// An app's tool as FoundationModels calls one: its arguments' schema made
|
|
391
|
+
/// at run time from the JSON schema, the arguments handed on as JSON.
|
|
392
|
+
struct BridgedTool: Tool {
|
|
393
|
+
let name: String
|
|
394
|
+
let description: String
|
|
395
|
+
let parameters: GenerationSchema
|
|
396
|
+
let run: @Sendable (JSONValue) async throws -> String
|
|
397
|
+
|
|
398
|
+
init(_ tool: LanguageModelTool) throws {
|
|
399
|
+
name = tool.name
|
|
400
|
+
description = tool.description
|
|
401
|
+
parameters = try GenerationSchema(root: Self.schema(tool.parameters, name: "\(tool.name)Arguments"), dependencies: [])
|
|
402
|
+
run = tool.call
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func call(arguments: GeneratedContent) async throws -> String {
|
|
406
|
+
try await run((try? JSONValue.parse(arguments.jsonString)) ?? .object([:]))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/// A JSON schema as a generation schema: objects, strings (enums as
|
|
410
|
+
/// choices), numbers, integers, booleans and arrays.
|
|
411
|
+
static func schema(_ json: JSONValue, name: String) -> DynamicGenerationSchema {
|
|
412
|
+
let object = json.object ?? [:]
|
|
413
|
+
let description = object["description"]?.string
|
|
414
|
+
if let choices = object["enum"]?.array?.compactMap(\.string), !choices.isEmpty {
|
|
415
|
+
return DynamicGenerationSchema(name: name, description: description, anyOf: choices)
|
|
416
|
+
}
|
|
417
|
+
switch object["type"]?.string {
|
|
418
|
+
case "object":
|
|
419
|
+
let required = Set((object["required"]?.array ?? []).compactMap(\.string))
|
|
420
|
+
let properties = (object["properties"]?.object ?? [:]).sorted { $0.key < $1.key }.map { key, value in
|
|
421
|
+
DynamicGenerationSchema.Property(
|
|
422
|
+
name: key,
|
|
423
|
+
description: value.object?["description"]?.string,
|
|
424
|
+
schema: schema(value, name: "\(name)_\(key)"),
|
|
425
|
+
isOptional: !required.contains(key)
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
return DynamicGenerationSchema(name: name, description: description, properties: properties)
|
|
429
|
+
case "array":
|
|
430
|
+
return DynamicGenerationSchema(arrayOf: schema(object["items"] ?? .object([:]), name: "\(name)_item"))
|
|
431
|
+
case "integer":
|
|
432
|
+
return DynamicGenerationSchema(type: Int.self)
|
|
433
|
+
case "number":
|
|
434
|
+
return DynamicGenerationSchema(type: Double.self)
|
|
435
|
+
case "boolean":
|
|
436
|
+
return DynamicGenerationSchema(type: Bool.self)
|
|
437
|
+
default:
|
|
438
|
+
return DynamicGenerationSchema(type: String.self)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@MainActor
|
|
444
|
+
final class Session: LanguageModelProviderSession {
|
|
445
|
+
private let session: LanguageModelSession
|
|
446
|
+
|
|
447
|
+
init(_ session: LanguageModelSession) {
|
|
448
|
+
self.session = session
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func prewarm() {
|
|
452
|
+
session.prewarm()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
func streamResponse(to prompt: String, options: LanguageModelOptions) -> AsyncThrowingStream<String, Error> {
|
|
456
|
+
let session = session
|
|
457
|
+
return AsyncThrowingStream { continuation in
|
|
458
|
+
let task = Task { @MainActor in
|
|
459
|
+
do {
|
|
460
|
+
for try await snapshot in session.streamResponse(to: prompt, options: FoundationModelsProvider.options(options)) {
|
|
461
|
+
continuation.yield(snapshot.content)
|
|
462
|
+
}
|
|
463
|
+
continuation.finish()
|
|
464
|
+
} catch {
|
|
465
|
+
continuation.finish(throwing: FoundationModelsProvider.tagged(error))
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
continuation.onTermination = { _ in task.cancel() }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func close() {}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
static func availability(_ availability: SystemLanguageModel.Availability) -> LanguageModelAvailability {
|
|
476
|
+
switch availability {
|
|
477
|
+
case .available:
|
|
478
|
+
return .available
|
|
479
|
+
case .unavailable(let reason):
|
|
480
|
+
switch reason {
|
|
481
|
+
case .deviceNotEligible: return .unavailable(.deviceNotEligible)
|
|
482
|
+
case .appleIntelligenceNotEnabled: return .unavailable(.appleIntelligenceNotEnabled)
|
|
483
|
+
case .modelNotReady: return .unavailable(.modelNotReady)
|
|
484
|
+
@unknown default: return .unavailable(.deviceNotEligible)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
static func options(_ options: LanguageModelOptions) -> GenerationOptions {
|
|
490
|
+
GenerationOptions(sampling: options.greedy ? .greedy : nil, temperature: options.temperature, maximumResponseTokens: options.maximumResponseTokens)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// A `GenerationError` as the error the app reads its case from.
|
|
494
|
+
static func tagged(_ error: Error) -> Error {
|
|
495
|
+
guard let error = error as? LanguageModelSession.GenerationError else { return error }
|
|
496
|
+
let kind: LanguageModelError.Kind
|
|
497
|
+
switch error {
|
|
498
|
+
case .exceededContextWindowSize: kind = .context
|
|
499
|
+
case .assetsUnavailable: kind = .unavailable
|
|
500
|
+
case .guardrailViolation: kind = .guardrail
|
|
501
|
+
case .unsupportedLanguageOrLocale: kind = .language
|
|
502
|
+
case .rateLimited: kind = .rateLimited
|
|
503
|
+
case .concurrentRequests: kind = .concurrent
|
|
504
|
+
case .refusal: kind = .refusal
|
|
505
|
+
default: return HostRequestFailure(error.localizedDescription)
|
|
506
|
+
}
|
|
507
|
+
return LanguageModelError(kind, error.localizedDescription)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
#endif
|
|
511
|
+
#endif
|