@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A compiled DSP program: the JSON @swiftbrowser/dsp's compiler writes
|
|
4
|
+
/// (packages/dsp/src/program.ts, which documents the format). The machine
|
|
5
|
+
/// keeps a register file of `registers` registers of 16 lanes, a lane per
|
|
6
|
+
/// voice; `block` runs once per block of up to 128 frames and `sample` once
|
|
7
|
+
/// per frame, each a flat list of instructions: an opcode, a destination
|
|
8
|
+
/// register, the opcode's operands and, for a stateful builtin, its instance.
|
|
9
|
+
public struct DSPProgram: Sendable, Equatable {
|
|
10
|
+
public struct Parameter: Sendable, Equatable {
|
|
11
|
+
public let name: String
|
|
12
|
+
public let register: Int
|
|
13
|
+
public let defaultValue: Float
|
|
14
|
+
public let min: Float
|
|
15
|
+
public let max: Float
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public struct State: Sendable, Equatable {
|
|
19
|
+
public let name: String
|
|
20
|
+
public let register: Int
|
|
21
|
+
public let initial: Float
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public let voices: Int
|
|
25
|
+
public let registers: Int
|
|
26
|
+
public let constants: [(register: Int, value: Float)]
|
|
27
|
+
public let parameters: [Parameter]
|
|
28
|
+
public let state: [State]
|
|
29
|
+
public let instances: [DSPBuiltin]
|
|
30
|
+
public let block: [Int32]
|
|
31
|
+
public let sample: [Int32]
|
|
32
|
+
public let output: Int
|
|
33
|
+
|
|
34
|
+
public static func == (a: DSPProgram, b: DSPProgram) -> Bool {
|
|
35
|
+
a.voices == b.voices && a.registers == b.registers && a.constants.map(\.register) == b.constants.map(\.register)
|
|
36
|
+
&& a.constants.map(\.value) == b.constants.map(\.value) && a.parameters == b.parameters && a.state == b.state
|
|
37
|
+
&& a.instances == b.instances && a.block == b.block && a.sample == b.sample && a.output == b.output
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public static let format = "swiftbrowser-dsp"
|
|
41
|
+
public static let version = 1
|
|
42
|
+
public static let lanes = 16
|
|
43
|
+
public static let blockFrames = 128
|
|
44
|
+
/// The machine's own registers: `sampleRate`, then a voice's `freq`, `gate`, `velocity` and `note`.
|
|
45
|
+
public static let machineRegisters = 5
|
|
46
|
+
|
|
47
|
+
/// The program the JSON describes, checked: every register, operand and instance in range.
|
|
48
|
+
public init(json: String) throws {
|
|
49
|
+
guard let data = json.data(using: .utf8) else { throw DSPProgramError("not UTF-8") }
|
|
50
|
+
try self.init(data: data)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public init(data: Data) throws {
|
|
54
|
+
let parsed: Any
|
|
55
|
+
do {
|
|
56
|
+
parsed = try JSONSerialization.jsonObject(with: data)
|
|
57
|
+
} catch {
|
|
58
|
+
throw DSPProgramError("not JSON (\(error.localizedDescription))")
|
|
59
|
+
}
|
|
60
|
+
try self.init(object: parsed)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// From a decoded JSON object (`JSONSerialization`'s).
|
|
64
|
+
public init(object: Any) throws {
|
|
65
|
+
guard let root = object as? [String: Any] else { throw DSPProgramError("not an object") }
|
|
66
|
+
guard root["format"] as? String == Self.format else { throw DSPProgramError("format is not \(Self.format)") }
|
|
67
|
+
guard Self.int(root["version"]) == Self.version else { throw DSPProgramError("version is not \(Self.version)") }
|
|
68
|
+
guard let voices = Self.int(root["voices"]), (1...Self.lanes).contains(voices) else { throw DSPProgramError("voices must be 1 … \(Self.lanes)") }
|
|
69
|
+
guard let registers = Self.int(root["registers"]), (Self.machineRegisters...65_536).contains(registers) else { throw DSPProgramError("registers out of range") }
|
|
70
|
+
func register(_ value: Any?, _ what: String) throws -> Int {
|
|
71
|
+
guard let r = Self.int(value), r >= 0, r < registers else { throw DSPProgramError("\(what): register out of range") }
|
|
72
|
+
return r
|
|
73
|
+
}
|
|
74
|
+
func number(_ value: Any?, _ what: String) throws -> Float {
|
|
75
|
+
guard let n = (value as? NSNumber)?.doubleValue, n.isFinite || n.isInfinite else { throw DSPProgramError("\(what) must be a number") }
|
|
76
|
+
return Float(n)
|
|
77
|
+
}
|
|
78
|
+
guard let instanceNames = root["instances"] as? [String] else { throw DSPProgramError("instances must be an array of names") }
|
|
79
|
+
let instances = try instanceNames.map { name in
|
|
80
|
+
guard let builtin = DSPBuiltin(rawValue: name) else { throw DSPProgramError("unknown builtin \(name)") }
|
|
81
|
+
return builtin
|
|
82
|
+
}
|
|
83
|
+
guard let constants = root["constants"] as? [[Any]] else { throw DSPProgramError("constants missing") }
|
|
84
|
+
let parsedConstants: [(register: Int, value: Float)] = try constants.map { entry in
|
|
85
|
+
guard entry.count == 2 else { throw DSPProgramError("a constant is not [register, value]") }
|
|
86
|
+
return (try register(entry[0], "constant"), try number(entry[1], "a constant"))
|
|
87
|
+
}
|
|
88
|
+
guard let parameters = root["parameters"] as? [[String: Any]] else { throw DSPProgramError("parameters missing") }
|
|
89
|
+
let parsedParameters = try parameters.map { p -> Parameter in
|
|
90
|
+
guard let name = p["name"] as? String else { throw DSPProgramError("a parameter has no name") }
|
|
91
|
+
let parameter = Parameter(
|
|
92
|
+
name: name, register: try register(p["register"], "parameter \(name)"),
|
|
93
|
+
defaultValue: try number(p["default"], "parameter \(name)'s default"),
|
|
94
|
+
min: try number(p["min"], "parameter \(name)'s min"), max: try number(p["max"], "parameter \(name)'s max")
|
|
95
|
+
)
|
|
96
|
+
guard parameter.min <= parameter.max, [parameter.defaultValue, parameter.min, parameter.max].allSatisfy(\.isFinite) else {
|
|
97
|
+
throw DSPProgramError("parameter \(name): its range is not numbers from min to max")
|
|
98
|
+
}
|
|
99
|
+
return parameter
|
|
100
|
+
}
|
|
101
|
+
guard let state = root["state"] as? [[String: Any]] else { throw DSPProgramError("state missing") }
|
|
102
|
+
let parsedState = try state.map { s -> State in
|
|
103
|
+
let name = s["name"] as? String ?? "state"
|
|
104
|
+
return State(name: name, register: try register(s["register"], "state \(name)"), initial: try number(s["initial"], "state \(name)'s initial value"))
|
|
105
|
+
}
|
|
106
|
+
let output = try register(root["output"], "output")
|
|
107
|
+
func code(_ key: String) throws -> [Int32] {
|
|
108
|
+
guard let words = root[key] as? [Any] else { throw DSPProgramError("\(key) must be an array") }
|
|
109
|
+
let code = try words.map { word -> Int32 in
|
|
110
|
+
guard let n = Self.int(word), n >= Int(Int32.min), n <= Int(Int32.max) else { throw DSPProgramError("\(key): a word is not a whole number") }
|
|
111
|
+
return Int32(n)
|
|
112
|
+
}
|
|
113
|
+
var pc = 0
|
|
114
|
+
while pc < code.count {
|
|
115
|
+
guard let op = DSPOpcode(rawValue: code[pc]) else { throw DSPProgramError("\(key): unknown opcode \(code[pc]) at \(pc)") }
|
|
116
|
+
let length = 2 + op.arity + (op.builtin == nil ? 0 : 1)
|
|
117
|
+
guard pc + length <= code.count else { throw DSPProgramError("\(key): \(op) at \(pc) is cut off") }
|
|
118
|
+
for i in 1..<(2 + op.arity) { _ = try register(Int(code[pc + i]), "\(key) \(op) at \(pc)") }
|
|
119
|
+
if let builtin = op.builtin {
|
|
120
|
+
let instance = Int(code[pc + length - 1])
|
|
121
|
+
guard instances.indices.contains(instance), instances[instance] == builtin else {
|
|
122
|
+
throw DSPProgramError("\(key): \(op) at \(pc) names instance \(instance), not a \(builtin)")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
pc += length
|
|
126
|
+
}
|
|
127
|
+
return code
|
|
128
|
+
}
|
|
129
|
+
self.voices = voices
|
|
130
|
+
self.registers = registers
|
|
131
|
+
self.constants = parsedConstants
|
|
132
|
+
self.parameters = parsedParameters
|
|
133
|
+
self.state = parsedState
|
|
134
|
+
self.instances = instances
|
|
135
|
+
self.block = try code("block")
|
|
136
|
+
self.sample = try code("sample")
|
|
137
|
+
self.output = output
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static func int(_ value: Any?) -> Int? {
|
|
141
|
+
guard let number = value as? NSNumber else { return nil }
|
|
142
|
+
let double = number.doubleValue
|
|
143
|
+
guard double.rounded() == double, abs(double) < 1e12 else { return nil }
|
|
144
|
+
return Int(double)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public struct DSPProgramError: Error, CustomStringConvertible, Equatable {
|
|
149
|
+
public let description: String
|
|
150
|
+
public init(_ detail: String) { description = "invalidProgram: \(detail)" }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The stateful builtins, by the name a program's `instances` gives them.
|
|
154
|
+
public enum DSPBuiltin: String, Sendable, CaseIterable {
|
|
155
|
+
case phasor, sine, saw, square, triangle, noise, adsr, lowpass, highpass, bandpass
|
|
156
|
+
|
|
157
|
+
/// Words of state per lane (program.ts BUILTIN_STATE).
|
|
158
|
+
var words: Int {
|
|
159
|
+
switch self {
|
|
160
|
+
case .phasor, .sine, .saw, .square, .triangle, .noise: 1
|
|
161
|
+
case .adsr, .lowpass, .highpass, .bandpass: 8
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The opcodes, v1: the numbers are the wire format (program.ts OPCODES).
|
|
167
|
+
public enum DSPOpcode: Int32, Sendable, CaseIterable {
|
|
168
|
+
case mov = 0, add, sub, mul, div, mod, neg, min, max, abs, floor, ceil, round, sqrt, sin, cos, tan, exp, log, pow, tanh
|
|
169
|
+
case lt = 21, le, gt, ge, eq, ne, and, or, not, select, clamp, mix, wrap, sign, atan
|
|
170
|
+
case phasor = 40, sine, saw, square, triangle, noise, adsr, lowpass, highpass, bandpass
|
|
171
|
+
|
|
172
|
+
/// Operands after the destination.
|
|
173
|
+
public var arity: Int {
|
|
174
|
+
switch self {
|
|
175
|
+
case .noise: 0
|
|
176
|
+
case .mov, .neg, .abs, .floor, .ceil, .round, .sqrt, .sin, .cos, .tan, .exp, .log, .tanh, .not, .wrap, .sign, .atan,
|
|
177
|
+
.phasor, .sine, .saw, .triangle: 1
|
|
178
|
+
case .select, .clamp, .mix, .lowpass, .highpass, .bandpass: 3
|
|
179
|
+
case .adsr: 5
|
|
180
|
+
default: 2
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// The builtin whose state it keeps, for a stateful one.
|
|
185
|
+
public var builtin: DSPBuiltin? {
|
|
186
|
+
rawValue >= 40 ? DSPBuiltin.allCases[Int(rawValue) - 40] : nil
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// swift-tools-version: 6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// SwiftBrowserLocalModel: an open language model on the device, through
|
|
5
|
+
// llama.cpp, for the `LanguageModelSession`s of a SwiftBrowser Native app
|
|
6
|
+
// where Apple Intelligence cannot answer (an iPhone before the 15 Pro, Apple
|
|
7
|
+
// Intelligence off, a system before iOS 26). A package of its own, so only an
|
|
8
|
+
// app that adds it fetches, builds and links llama.cpp; it installs itself as
|
|
9
|
+
// `LanguageModels.fallback` (SwiftBrowserNative, LanguageModelProvider.swift).
|
|
10
|
+
//
|
|
11
|
+
// llama.cpp is its release XCFramework, b10456: the newest whose release
|
|
12
|
+
// carries the iOS Simulator beside devices and macOS (later ones ship devices
|
|
13
|
+
// and macOS only). SwiftPM fetches it once (286 MB, tvOS and visionOS
|
|
14
|
+
// included) and caches it; an iPhone app links about 6 MB of it.
|
|
15
|
+
let package = Package(
|
|
16
|
+
name: "SwiftBrowserLocalModel",
|
|
17
|
+
platforms: [.iOS(.v17), .macOS(.v14)],
|
|
18
|
+
products: [
|
|
19
|
+
.library(name: "SwiftBrowserLocalModel", targets: ["SwiftBrowserLocalModel"]),
|
|
20
|
+
],
|
|
21
|
+
dependencies: [
|
|
22
|
+
.package(path: "../.."),
|
|
23
|
+
],
|
|
24
|
+
targets: [
|
|
25
|
+
.binaryTarget(
|
|
26
|
+
name: "llama",
|
|
27
|
+
url: "https://github.com/ggml-org/llama.cpp/releases/download/b10456/llama-b10456-xcframework.zip",
|
|
28
|
+
checksum: "0223bedd0a01232399d943dcb72bc227882bc90df98e29d7a92343531a88cc02"
|
|
29
|
+
),
|
|
30
|
+
.target(
|
|
31
|
+
name: "SwiftBrowserLocalModel",
|
|
32
|
+
dependencies: [.product(name: "SwiftBrowserNative", package: "swift"), "llama"]
|
|
33
|
+
),
|
|
34
|
+
]
|
|
35
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SwiftBrowserLocalModel
|
|
2
|
+
|
|
3
|
+
An open language model on the device, run with
|
|
4
|
+
[llama.cpp](https://github.com/ggml-org/llama.cpp), for a SwiftBrowser Native
|
|
5
|
+
app's `LanguageModelSession`s where Apple Intelligence cannot answer: an
|
|
6
|
+
iPhone before the 15 Pro, Apple Intelligence turned off, a system before
|
|
7
|
+
iOS 26. Where Apple Intelligence answers, it still does.
|
|
8
|
+
|
|
9
|
+
It is a package of its own so that only an app that adds it fetches, builds
|
|
10
|
+
and links llama.cpp. @swiftbrowser/native ships it beside the core
|
|
11
|
+
(`node_modules/@swiftbrowser/native/swift/Packages/SwiftBrowserLocalModel`;
|
|
12
|
+
it depends on `SwiftBrowserNative` at `../..`, here and there). Link it into
|
|
13
|
+
the app's Xcode project with
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx swiftbrowser native add local-model # or native init --with local-model
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
and install a model at launch:
|
|
20
|
+
|
|
21
|
+
```swift
|
|
22
|
+
import SwiftBrowserLocalModel
|
|
23
|
+
|
|
24
|
+
init() {
|
|
25
|
+
LocalLanguageModel.install([.llama3_2_1B, .qwen2_5_1_5B, .llama3_2_3B])
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With several, the first is the default and availability lists them as
|
|
30
|
+
`models` for the viewer to choose from (`selectModel(id)`), the choice
|
|
31
|
+
remembered across launches; each downloads on its own.
|
|
32
|
+
|
|
33
|
+
On the JavaScript side, `useSystemLanguageModel()` reports `modelNotReady`
|
|
34
|
+
with `download: { size, downloaded: false }` until the viewer asks for the
|
|
35
|
+
model: show the size and call `SystemLanguageModel.default.download()` from
|
|
36
|
+
a button. The download's `progress` follows, then `available`, and
|
|
37
|
+
`LanguageModelSession` answers with it. `deleteDownload()` stops a download
|
|
38
|
+
or takes the model off the device (the Showcase's Chat has both).
|
|
39
|
+
|
|
40
|
+
- **Tools** a session is given are offered in its instructions, and a reply
|
|
41
|
+
that is just `{"tool": name, "arguments": {...}}` is run as a call, its
|
|
42
|
+
result handed back for the model to go on. Small models call tools less
|
|
43
|
+
reliably than Apple Intelligence does; Qwen 2.5 1.5B is the steadiest here.
|
|
44
|
+
- **The models**: `.llama3_2_1B` (808 MB, fastest), `.qwen2_5_1_5B` (1.1 GB,
|
|
45
|
+
Apache 2.0, best with tools), `.llama3_2_3B` (2.0 GB, most capable; a phone
|
|
46
|
+
with 6 GB of memory runs it near its limit).
|
|
47
|
+
- **The model** is a GGUF file, fetched when the app calls `download()`, kept
|
|
48
|
+
in Application Support's
|
|
49
|
+
`SwiftBrowserLocalModel` folder and excluded from backups. `.llama3_2_1B`
|
|
50
|
+
is Meta's Llama 3.2 1B Instruct at 4 bits (808 MB; Meta's Llama 3.2
|
|
51
|
+
Community License applies); any other is a `LocalLanguageModel.Model`
|
|
52
|
+
with its URL, size and context length. `allowsCellularAccess: false`
|
|
53
|
+
waits for Wi-Fi.
|
|
54
|
+
- **Running it**: loaded once on the first prompt or `prewarm()`, on the GPU
|
|
55
|
+
with Metal, on a queue of its own. A session keeps its messages, formatted
|
|
56
|
+
with the model's chat template (ChatML for a model without one); the
|
|
57
|
+
context keeps the tokens of the last conversation, so a prompt that
|
|
58
|
+
continues it decodes only what is new. A conversation longer than the
|
|
59
|
+
context fails as `exceededContextWindowSize`.
|
|
60
|
+
- **Size**: llama.cpp is its release XCFramework, b10456, the newest whose
|
|
61
|
+
release has the iOS Simulator beside devices and macOS. SwiftPM downloads
|
|
62
|
+
it once (286 MB, with tvOS and visionOS) and caches it; an iPhone app
|
|
63
|
+
links about 6 MB.
|
|
64
|
+
|
|
65
|
+
The tests (`swift test`, macOS) run llama.cpp on the CPU with a 1.2 MB toy
|
|
66
|
+
model they download.
|
package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import llama
|
|
3
|
+
import SwiftBrowserNative
|
|
4
|
+
|
|
5
|
+
/// A model loaded with llama.cpp and the one context it answers in, used
|
|
6
|
+
/// from a serial queue of its own (every call into llama.cpp blocks). The
|
|
7
|
+
/// context keeps the tokens of the last conversation it saw, so a prompt that
|
|
8
|
+
/// continues it decodes only what is new.
|
|
9
|
+
final class LlamaEngine: @unchecked Sendable {
|
|
10
|
+
struct Message: Sendable, Equatable {
|
|
11
|
+
let role: String
|
|
12
|
+
let content: String
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let contextLength: Int
|
|
16
|
+
private let queue = DispatchQueue(label: "dev.swiftbrowser.localmodel.llama", qos: .userInitiated)
|
|
17
|
+
private let model: OpaquePointer
|
|
18
|
+
private let context: OpaquePointer
|
|
19
|
+
private let vocab: OpaquePointer
|
|
20
|
+
/// The model's chat template, or ChatML for a model without one.
|
|
21
|
+
private let template: String
|
|
22
|
+
/// The tokens the context holds, in order.
|
|
23
|
+
private var cached: [llama_token] = []
|
|
24
|
+
|
|
25
|
+
/// llama.cpp's backends, started once, with its logging silenced.
|
|
26
|
+
private static let backend: Void = {
|
|
27
|
+
llama_backend_init()
|
|
28
|
+
llama_log_set({ _, _, _ in }, nil)
|
|
29
|
+
}()
|
|
30
|
+
|
|
31
|
+
/// Loads the model at `path`. Blocking: call it off the main thread.
|
|
32
|
+
init(path: String, contextLength: Int, gpuLayers: Int32) throws {
|
|
33
|
+
_ = Self.backend
|
|
34
|
+
var modelParams = llama_model_default_params()
|
|
35
|
+
modelParams.n_gpu_layers = gpuLayers
|
|
36
|
+
guard let model = llama_model_load_from_file(path, modelParams) else {
|
|
37
|
+
throw LanguageModelError(.unavailable, "the model could not be loaded")
|
|
38
|
+
}
|
|
39
|
+
var contextParams = llama_context_default_params()
|
|
40
|
+
contextParams.n_ctx = UInt32(contextLength)
|
|
41
|
+
contextParams.n_batch = 512
|
|
42
|
+
let threads = Int32(max(1, min(8, ProcessInfo.processInfo.activeProcessorCount - 2)))
|
|
43
|
+
contextParams.n_threads = threads
|
|
44
|
+
contextParams.n_threads_batch = threads
|
|
45
|
+
guard let context = llama_init_from_model(model, contextParams) else {
|
|
46
|
+
llama_model_free(model)
|
|
47
|
+
throw LanguageModelError(.unavailable, "the model's context could not be made")
|
|
48
|
+
}
|
|
49
|
+
guard let vocab = llama_model_get_vocab(model) else {
|
|
50
|
+
llama_free(context)
|
|
51
|
+
llama_model_free(model)
|
|
52
|
+
throw LanguageModelError(.unavailable, "the model has no vocabulary")
|
|
53
|
+
}
|
|
54
|
+
self.model = model
|
|
55
|
+
self.context = context
|
|
56
|
+
self.vocab = vocab
|
|
57
|
+
self.contextLength = Int(llama_n_ctx(context))
|
|
58
|
+
template = llama_model_chat_template(model, nil).map { String(cString: $0) } ?? "chatml"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// The role a tool's result goes back to the model as: Llama 3's `ipython`, else `tool`.
|
|
62
|
+
var toolRole: String {
|
|
63
|
+
template.contains("<|start_header_id|>") ? "ipython" : "tool"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
deinit {
|
|
67
|
+
llama_free(context)
|
|
68
|
+
llama_model_free(model)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The reply to the last message of `messages`, calling `onText` with the
|
|
72
|
+
/// reply so far as it grows; stops early once `cancelled` is set, with
|
|
73
|
+
/// what came by then.
|
|
74
|
+
func generate(
|
|
75
|
+
_ messages: [Message],
|
|
76
|
+
options: LanguageModelOptions,
|
|
77
|
+
cancelled: CancelFlag,
|
|
78
|
+
onText: @escaping @Sendable (String) -> Void
|
|
79
|
+
) async throws -> String {
|
|
80
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
81
|
+
queue.async {
|
|
82
|
+
do {
|
|
83
|
+
continuation.resume(returning: try self.generateNow(messages, options: options, cancelled: cancelled, onText: onText))
|
|
84
|
+
} catch {
|
|
85
|
+
continuation.resume(throwing: error)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func generateNow(_ messages: [Message], options: LanguageModelOptions, cancelled: CancelFlag, onText: (String) -> Void) throws -> String {
|
|
92
|
+
let tokens = tokenize(try format(messages))
|
|
93
|
+
guard tokens.count + 16 < contextLength else {
|
|
94
|
+
throw LanguageModelError(.context, "the conversation is longer than the model's \(contextLength) tokens")
|
|
95
|
+
}
|
|
96
|
+
// Keep what the context already holds of this conversation; decode the rest.
|
|
97
|
+
var kept = 0
|
|
98
|
+
while kept < min(cached.count, tokens.count), cached[kept] == tokens[kept] { kept += 1 }
|
|
99
|
+
if kept == tokens.count { kept -= 1 }
|
|
100
|
+
let memory = llama_get_memory(context)
|
|
101
|
+
if !llama_memory_seq_rm(memory, 0, llama_pos(kept), -1) {
|
|
102
|
+
llama_memory_clear(memory, true)
|
|
103
|
+
kept = 0
|
|
104
|
+
}
|
|
105
|
+
cached.removeSubrange(min(kept, cached.count)...)
|
|
106
|
+
var start = kept
|
|
107
|
+
while start < tokens.count {
|
|
108
|
+
if cancelled.isCancelled { return "" }
|
|
109
|
+
let end = min(start + 512, tokens.count)
|
|
110
|
+
try decode(Array(tokens[start..<end]))
|
|
111
|
+
cached.append(contentsOf: tokens[start..<end])
|
|
112
|
+
start = end
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let sampler = llama_sampler_chain_init(llama_sampler_chain_default_params())
|
|
116
|
+
defer { llama_sampler_free(sampler) }
|
|
117
|
+
if options.greedy {
|
|
118
|
+
llama_sampler_chain_add(sampler, llama_sampler_init_greedy())
|
|
119
|
+
} else {
|
|
120
|
+
llama_sampler_chain_add(sampler, llama_sampler_init_top_k(40))
|
|
121
|
+
llama_sampler_chain_add(sampler, llama_sampler_init_top_p(0.9, 1))
|
|
122
|
+
llama_sampler_chain_add(sampler, llama_sampler_init_temp(Float(options.temperature ?? 0.7)))
|
|
123
|
+
llama_sampler_chain_add(sampler, llama_sampler_init_dist(UInt32.random(in: 0...UInt32.max)))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var bytes: [UInt8] = []
|
|
127
|
+
let limit = options.maximumResponseTokens ?? contextLength
|
|
128
|
+
for _ in 0..<limit {
|
|
129
|
+
if cancelled.isCancelled || cached.count >= contextLength - 1 { break }
|
|
130
|
+
let token = llama_sampler_sample(sampler, context, -1)
|
|
131
|
+
if llama_vocab_is_eog(vocab, token) { break }
|
|
132
|
+
bytes += piece(token)
|
|
133
|
+
// A character split across tokens waits for its last byte.
|
|
134
|
+
if let text = String(bytes: bytes, encoding: .utf8) { onText(Self.trimmed(text)) }
|
|
135
|
+
try decode([token])
|
|
136
|
+
cached.append(token)
|
|
137
|
+
}
|
|
138
|
+
return Self.trimmed(String(decoding: bytes, as: UTF8.self))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// The conversation in the model's chat template, ending where the assistant's reply starts.
|
|
142
|
+
func format(_ messages: [Message]) throws -> String {
|
|
143
|
+
if let text = format(messages, template: template) ?? format(messages, template: "chatml") { return text }
|
|
144
|
+
throw LanguageModelError(.unavailable, "the model's chat template is not supported")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func format(_ messages: [Message], template: String) -> String? {
|
|
148
|
+
let roles = messages.map { strdup($0.role) }
|
|
149
|
+
let contents = messages.map { strdup($0.content) }
|
|
150
|
+
defer {
|
|
151
|
+
roles.forEach { free($0) }
|
|
152
|
+
contents.forEach { free($0) }
|
|
153
|
+
}
|
|
154
|
+
var chat = messages.indices.map { llama_chat_message(role: UnsafePointer(roles[$0]), content: UnsafePointer(contents[$0])) }
|
|
155
|
+
let count = chat.count
|
|
156
|
+
var capacity = max(1024, messages.reduce(0) { $0 + $1.content.utf8.count } * 2 + 512)
|
|
157
|
+
var buffer = [CChar](repeating: 0, count: capacity)
|
|
158
|
+
var length = llama_chat_apply_template(template, &chat, count, true, &buffer, Int32(capacity))
|
|
159
|
+
if length > capacity {
|
|
160
|
+
capacity = Int(length) + 1
|
|
161
|
+
buffer = [CChar](repeating: 0, count: capacity)
|
|
162
|
+
length = llama_chat_apply_template(template, &chat, count, true, &buffer, Int32(capacity))
|
|
163
|
+
}
|
|
164
|
+
guard length >= 0 else { return nil }
|
|
165
|
+
return String(decoding: buffer.prefix(Int(length)).map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func tokenize(_ text: String) -> [llama_token] {
|
|
169
|
+
let length = Int32(text.utf8.count)
|
|
170
|
+
var capacity = length + 8
|
|
171
|
+
var tokens = [llama_token](repeating: 0, count: Int(capacity))
|
|
172
|
+
var count = llama_tokenize(vocab, text, length, &tokens, capacity, true, true)
|
|
173
|
+
if count < 0 {
|
|
174
|
+
capacity = -count
|
|
175
|
+
tokens = [llama_token](repeating: 0, count: Int(capacity))
|
|
176
|
+
count = llama_tokenize(vocab, text, length, &tokens, capacity, true, true)
|
|
177
|
+
}
|
|
178
|
+
return Array(tokens.prefix(Int(max(count, 0))))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func piece(_ token: llama_token) -> [UInt8] {
|
|
182
|
+
var capacity: Int32 = 64
|
|
183
|
+
var buffer = [CChar](repeating: 0, count: Int(capacity))
|
|
184
|
+
var count = llama_token_to_piece(vocab, token, &buffer, capacity, 0, false)
|
|
185
|
+
if count < 0 {
|
|
186
|
+
capacity = -count
|
|
187
|
+
buffer = [CChar](repeating: 0, count: Int(capacity))
|
|
188
|
+
count = llama_token_to_piece(vocab, token, &buffer, capacity, 0, false)
|
|
189
|
+
}
|
|
190
|
+
return buffer.prefix(Int(max(count, 0))).map { UInt8(bitPattern: $0) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Reads `tokens` into the context; a failure leaves it empty, so the next prompt starts over.
|
|
194
|
+
private func decode(_ tokens: [llama_token]) throws {
|
|
195
|
+
var tokens = tokens
|
|
196
|
+
let result = tokens.withUnsafeMutableBufferPointer { buffer in
|
|
197
|
+
llama_decode(context, llama_batch_get_one(buffer.baseAddress, Int32(buffer.count)))
|
|
198
|
+
}
|
|
199
|
+
guard result == 0 else {
|
|
200
|
+
llama_memory_clear(llama_get_memory(context), true)
|
|
201
|
+
cached = []
|
|
202
|
+
throw LanguageModelError(result == 1 ? .context : .unavailable, "the model could not read the conversation (\(result))")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private static func trimmed(_ text: String) -> String {
|
|
207
|
+
String(text.drop(while: \.isWhitespace))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Set once a response should stop; read from the engine's queue.
|
|
212
|
+
final class CancelFlag: @unchecked Sendable {
|
|
213
|
+
private let lock = NSLock()
|
|
214
|
+
private var cancelled = false
|
|
215
|
+
|
|
216
|
+
var isCancelled: Bool {
|
|
217
|
+
lock.withLock { cancelled }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func cancel() {
|
|
221
|
+
lock.withLock { cancelled = true }
|
|
222
|
+
}
|
|
223
|
+
}
|