@swiftbrowser/native 0.18.3 → 0.21.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 +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- 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 +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.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 +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -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/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- 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/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- 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 +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
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
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftBrowserNative
|
|
3
|
+
|
|
4
|
+
/// Open language models run on the device with llama.cpp, for the app's
|
|
5
|
+
/// `LanguageModelSession`s where Apple Intelligence cannot answer (or where
|
|
6
|
+
/// the viewer chooses one). Install one, or several to choose from, at launch:
|
|
7
|
+
///
|
|
8
|
+
/// ```swift
|
|
9
|
+
/// LocalLanguageModel.install([.llama3_2_1B, .qwen2_5_1_5B, .llama3_2_3B])
|
|
10
|
+
/// ```
|
|
11
|
+
///
|
|
12
|
+
/// The first is the default; the viewer's choice (`selectModel` in
|
|
13
|
+
/// @swiftbrowser/react) is remembered across launches.
|
|
14
|
+
///
|
|
15
|
+
/// The model is a GGUF file the app fetches when the viewer asks for it
|
|
16
|
+
/// (`SystemLanguageModel.default.download()` in @swiftbrowser/react, after
|
|
17
|
+
/// showing its size; until it is here availability is `modelNotReady`, with
|
|
18
|
+
/// the download's state), kept in Application Support and excluded from
|
|
19
|
+
/// backups, loaded on the first prompt or `prewarm()`, and removed with
|
|
20
|
+
/// `deleteDownload()`.
|
|
21
|
+
public enum LocalLanguageModel {
|
|
22
|
+
/// A GGUF model to fetch and run.
|
|
23
|
+
public struct Model: Sendable, Equatable {
|
|
24
|
+
/// How the app names it to the viewer.
|
|
25
|
+
public var name: String
|
|
26
|
+
/// A line about it: what it is good at.
|
|
27
|
+
public var summary: String?
|
|
28
|
+
/// The file's name on the device, which is also the model's id.
|
|
29
|
+
public var fileName: String
|
|
30
|
+
public var url: URL
|
|
31
|
+
/// The file's size, checked once it has downloaded; also the download's
|
|
32
|
+
/// progress where the server does not say how big the file is.
|
|
33
|
+
public var bytes: Int64?
|
|
34
|
+
/// How many tokens of the conversation the model keeps in mind.
|
|
35
|
+
public var contextLength: Int
|
|
36
|
+
/// Layers run on the GPU with Metal (all of them by default); 0 runs on the CPU.
|
|
37
|
+
public var gpuLayers: Int32
|
|
38
|
+
|
|
39
|
+
public init(name: String, summary: String? = nil, fileName: String, url: URL, bytes: Int64? = nil, contextLength: Int = 4096, gpuLayers: Int32 = 999) {
|
|
40
|
+
self.name = name
|
|
41
|
+
self.summary = summary
|
|
42
|
+
self.fileName = fileName
|
|
43
|
+
self.url = url
|
|
44
|
+
self.bytes = bytes
|
|
45
|
+
self.contextLength = contextLength
|
|
46
|
+
self.gpuLayers = gpuLayers
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Meta's Llama 3.2 1B Instruct, 4-bit (Q4_K_M): 808 MB, at home on a
|
|
50
|
+
/// phone with 6 GB of memory. Built with Llama; Meta's Llama 3.2
|
|
51
|
+
/// Community License applies.
|
|
52
|
+
public static let llama3_2_1B = Model(
|
|
53
|
+
name: "Llama 3.2 1B",
|
|
54
|
+
summary: "Fastest",
|
|
55
|
+
fileName: "Llama-3.2-1B-Instruct-Q4_K_M.gguf",
|
|
56
|
+
url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf")!,
|
|
57
|
+
bytes: 807_694_464
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/// Alibaba's Qwen 2.5 1.5B Instruct, 4-bit (Q4_K_M): 1.1 GB, the
|
|
61
|
+
/// steadiest of these with tools. Apache 2.0.
|
|
62
|
+
public static let qwen2_5_1_5B = Model(
|
|
63
|
+
name: "Qwen 2.5 1.5B",
|
|
64
|
+
summary: "Best with tools",
|
|
65
|
+
fileName: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
66
|
+
url: URL(string: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf")!,
|
|
67
|
+
bytes: 1_117_320_736
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/// Meta's Llama 3.2 3B Instruct, 4-bit (Q4_K_M): 2.0 GB, the most
|
|
71
|
+
/// capable here, and the most memory (a phone with 6 GB runs it close
|
|
72
|
+
/// to its limit). Built with Llama; Meta's Llama 3.2 Community License applies.
|
|
73
|
+
public static let llama3_2_3B = Model(
|
|
74
|
+
name: "Llama 3.2 3B",
|
|
75
|
+
summary: "Most capable",
|
|
76
|
+
fileName: "Llama-3.2-3B-Instruct-Q4_K_M.gguf",
|
|
77
|
+
url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf")!,
|
|
78
|
+
bytes: 2_019_377_696
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Makes `models` the app's `LanguageModels.fallback`, the first the
|
|
83
|
+
/// default. `directory` is where the files are kept (Application
|
|
84
|
+
/// Support's SwiftBrowserLocalModel folder by default);
|
|
85
|
+
/// `allowsCellularAccess` false waits for Wi-Fi to fetch them.
|
|
86
|
+
@MainActor
|
|
87
|
+
public static func install(_ models: [Model], directory: URL? = nil, allowsCellularAccess: Bool = true) {
|
|
88
|
+
LanguageModels.fallback = provider(models, directory: directory, allowsCellularAccess: allowsCellularAccess)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@MainActor
|
|
92
|
+
public static func install(_ model: Model, directory: URL? = nil, allowsCellularAccess: Bool = true) {
|
|
93
|
+
install([model], directory: directory, allowsCellularAccess: allowsCellularAccess)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The provider `install` sets, to use some other way. `remembers` keeps
|
|
97
|
+
/// the viewer's choice across launches (in the standard user defaults).
|
|
98
|
+
@MainActor
|
|
99
|
+
public static func provider(
|
|
100
|
+
_ models: [Model],
|
|
101
|
+
directory: URL? = nil,
|
|
102
|
+
allowsCellularAccess: Bool = true,
|
|
103
|
+
remembers: Bool = true
|
|
104
|
+
) -> any LanguageModelProvider {
|
|
105
|
+
precondition(!models.isEmpty, "LocalLanguageModel: no models")
|
|
106
|
+
let directory = directory ?? URL.applicationSupportDirectory.appendingPathComponent("SwiftBrowserLocalModel", isDirectory: true)
|
|
107
|
+
return LlamaProvider(
|
|
108
|
+
models: models,
|
|
109
|
+
stores: models.map { ModelStore(model: $0, directory: directory, allowsCellularAccess: allowsCellularAccess) },
|
|
110
|
+
remembers: remembers
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@MainActor
|
|
115
|
+
public static func provider(_ model: Model, directory: URL? = nil, allowsCellularAccess: Bool = true) -> any LanguageModelProvider {
|
|
116
|
+
provider([model], directory: directory, allowsCellularAccess: allowsCellularAccess, remembers: false)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// The model's file: on the device, or on its way.
|
|
121
|
+
@MainActor
|
|
122
|
+
final class ModelStore {
|
|
123
|
+
let model: LocalLanguageModel.Model
|
|
124
|
+
let file: URL
|
|
125
|
+
private let directory: URL
|
|
126
|
+
private let session: URLSession
|
|
127
|
+
private var download: Task<Void, Never>?
|
|
128
|
+
private var watcher: DownloadWatcher?
|
|
129
|
+
/// Why the last download failed; the next request for availability tries again.
|
|
130
|
+
private(set) var failure: Error?
|
|
131
|
+
|
|
132
|
+
init(model: LocalLanguageModel.Model, directory: URL, allowsCellularAccess: Bool) {
|
|
133
|
+
self.model = model
|
|
134
|
+
self.directory = directory
|
|
135
|
+
file = directory.appendingPathComponent(model.fileName)
|
|
136
|
+
let configuration = URLSessionConfiguration.default
|
|
137
|
+
configuration.allowsCellularAccess = allowsCellularAccess
|
|
138
|
+
configuration.timeoutIntervalForResource = 6 * 60 * 60
|
|
139
|
+
session = URLSession(configuration: configuration)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var isReady: Bool {
|
|
143
|
+
FileManager.default.fileExists(atPath: file.path)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
var isFetching: Bool {
|
|
147
|
+
download != nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// How much of the file has come, from 0 to 1.
|
|
151
|
+
var progress: Double {
|
|
152
|
+
guard let task = watcher?.task else { return 0 }
|
|
153
|
+
let expected = task.countOfBytesExpectedToReceive > 0 ? task.countOfBytesExpectedToReceive : model.bytes ?? 0
|
|
154
|
+
guard expected > 0 else { return 0 }
|
|
155
|
+
return min(1, Double(task.countOfBytesReceived) / Double(expected))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Starts fetching the file, unless it is here or on its way.
|
|
159
|
+
func fetch() {
|
|
160
|
+
guard !isReady, download == nil else { return }
|
|
161
|
+
failure = nil
|
|
162
|
+
let watcher = DownloadWatcher()
|
|
163
|
+
self.watcher = watcher
|
|
164
|
+
download = Task { @MainActor in
|
|
165
|
+
do {
|
|
166
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
167
|
+
let (temporary, response) = try await session.download(from: model.url, delegate: watcher)
|
|
168
|
+
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
169
|
+
throw LanguageModelError(.unavailable, "the model's download answered \(http.statusCode)")
|
|
170
|
+
}
|
|
171
|
+
let size = (try FileManager.default.attributesOfItem(atPath: temporary.path)[.size] as? NSNumber)?.int64Value
|
|
172
|
+
if let bytes = model.bytes, size != bytes {
|
|
173
|
+
try? FileManager.default.removeItem(at: temporary)
|
|
174
|
+
throw LanguageModelError(.unavailable, "the model's download was \(size ?? 0) bytes, not \(bytes)")
|
|
175
|
+
}
|
|
176
|
+
try? FileManager.default.removeItem(at: file)
|
|
177
|
+
try FileManager.default.moveItem(at: temporary, to: file)
|
|
178
|
+
var values = URLResourceValues()
|
|
179
|
+
values.isExcludedFromBackup = true
|
|
180
|
+
var kept = file
|
|
181
|
+
try? kept.setResourceValues(values)
|
|
182
|
+
failure = nil
|
|
183
|
+
} catch {
|
|
184
|
+
if !Task.isCancelled { failure = error }
|
|
185
|
+
}
|
|
186
|
+
download = nil
|
|
187
|
+
self.watcher = nil
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Stops a download under way and removes the file.
|
|
192
|
+
func remove() async {
|
|
193
|
+
if let download {
|
|
194
|
+
download.cancel()
|
|
195
|
+
await download.value
|
|
196
|
+
}
|
|
197
|
+
failure = nil
|
|
198
|
+
try? FileManager.default.removeItem(at: file)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Hears which task a download made, so its progress can be read.
|
|
203
|
+
private final class DownloadWatcher: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|
204
|
+
private let lock = NSLock()
|
|
205
|
+
private var current: URLSessionTask?
|
|
206
|
+
|
|
207
|
+
var task: URLSessionTask? {
|
|
208
|
+
lock.withLock { current }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
|
212
|
+
lock.withLock { current = task }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// The models as `LanguageModels.fallback`: the chosen one available once
|
|
217
|
+
/// its file is here, loaded once for every session.
|
|
218
|
+
@MainActor
|
|
219
|
+
final class LlamaProvider: LanguageModelProvider {
|
|
220
|
+
let models: [LocalLanguageModel.Model]
|
|
221
|
+
private let stores: [String: ModelStore]
|
|
222
|
+
private let remembers: Bool
|
|
223
|
+
private static let selectionKey = "SwiftBrowserLocalModel.selected"
|
|
224
|
+
private(set) var selected: String
|
|
225
|
+
/// The loaded model and which it is.
|
|
226
|
+
private var engine: (id: String, task: Task<LlamaEngine, Error>)?
|
|
227
|
+
|
|
228
|
+
init(models: [LocalLanguageModel.Model], stores: [ModelStore], remembers: Bool) {
|
|
229
|
+
self.models = models
|
|
230
|
+
self.stores = Dictionary(uniqueKeysWithValues: stores.map { ($0.model.fileName, $0) })
|
|
231
|
+
self.remembers = remembers
|
|
232
|
+
let remembered = remembers ? UserDefaults.standard.string(forKey: Self.selectionKey) : nil
|
|
233
|
+
selected = models.first { $0.fileName == remembered }?.fileName ?? models[0].fileName
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// The chosen model's file.
|
|
237
|
+
var store: ModelStore { stores[selected]! }
|
|
238
|
+
|
|
239
|
+
func availability() async -> LanguageModelAvailability {
|
|
240
|
+
store.isReady ? .available : .unavailable(.modelNotReady)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
var downloadState: LanguageModelDownload? {
|
|
244
|
+
Self.state(of: store)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
var choices: [LanguageModelChoice] {
|
|
248
|
+
models.map { LanguageModelChoice(id: $0.fileName, name: $0.name, summary: $0.summary, download: Self.state(of: stores[$0.fileName]!)) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
var selectedChoice: String? { selected }
|
|
252
|
+
|
|
253
|
+
func select(_ id: String) {
|
|
254
|
+
guard stores[id] != nil, id != selected else { return }
|
|
255
|
+
selected = id
|
|
256
|
+
if remembers { UserDefaults.standard.set(id, forKey: Self.selectionKey) }
|
|
257
|
+
// The model chosen before goes once a response under way lets it go.
|
|
258
|
+
engine = nil
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private static func state(of store: ModelStore) -> LanguageModelDownload {
|
|
262
|
+
LanguageModelDownload(
|
|
263
|
+
size: store.model.bytes,
|
|
264
|
+
isDownloaded: store.isReady,
|
|
265
|
+
progress: store.isFetching ? store.progress : nil,
|
|
266
|
+
failure: store.failure.map { ($0 as? LanguageModelError)?.message ?? $0.localizedDescription }
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func startDownload() {
|
|
271
|
+
store.fetch()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func deleteDownload() async throws {
|
|
275
|
+
// The loaded model goes once a response under way has let it go.
|
|
276
|
+
if engine?.id == selected { engine = nil }
|
|
277
|
+
await store.remove()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func makeSession(instructions: String?, tools: [LanguageModelTool]) throws -> any LanguageModelProviderSession {
|
|
281
|
+
LlamaSession(provider: self, instructions: instructions, tools: tools)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// The chosen model, loading it the first time (off the main thread).
|
|
285
|
+
func loaded() async throws -> LlamaEngine {
|
|
286
|
+
if let engine, engine.id == selected { return try await engine.task.value }
|
|
287
|
+
let id = selected
|
|
288
|
+
let path = store.file.path
|
|
289
|
+
let model = store.model
|
|
290
|
+
let loading = Task.detached(priority: .userInitiated) {
|
|
291
|
+
try LlamaEngine(path: path, contextLength: model.contextLength, gpuLayers: model.gpuLayers)
|
|
292
|
+
}
|
|
293
|
+
engine = (id, loading)
|
|
294
|
+
do {
|
|
295
|
+
return try await loading.value
|
|
296
|
+
} catch {
|
|
297
|
+
if engine?.id == id { engine = nil }
|
|
298
|
+
throw error
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// One conversation: its messages, each prompt answered after the ones
|
|
304
|
+
/// before. Tools are offered in the instructions: the model calls one by
|
|
305
|
+
/// answering with just `{"tool": name, "arguments": {...}}`, the session runs
|
|
306
|
+
/// it and hands the model the result, then lets it answer (a few rounds at
|
|
307
|
+
/// most); a reply that starts as such an object is held back until it is
|
|
308
|
+
/// clear whether it is a call.
|
|
309
|
+
@MainActor
|
|
310
|
+
final class LlamaSession: LanguageModelProviderSession {
|
|
311
|
+
private let provider: LlamaProvider
|
|
312
|
+
private let tools: [LanguageModelTool]
|
|
313
|
+
private(set) var messages: [LlamaEngine.Message]
|
|
314
|
+
/// How many tool calls one prompt may make before the model must answer.
|
|
315
|
+
nonisolated static let toolRounds = 4
|
|
316
|
+
|
|
317
|
+
init(provider: LlamaProvider, instructions: String?, tools: [LanguageModelTool]) {
|
|
318
|
+
self.provider = provider
|
|
319
|
+
self.tools = tools
|
|
320
|
+
let system = Self.systemPrompt(instructions: instructions, tools: tools)
|
|
321
|
+
messages = system.map { [LlamaEngine.Message(role: "system", content: $0)] } ?? []
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func prewarm() {
|
|
325
|
+
let provider = provider
|
|
326
|
+
Task { _ = try? await provider.loaded() }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
func streamResponse(to prompt: String, options: LanguageModelOptions) -> AsyncThrowingStream<String, Error> {
|
|
330
|
+
let provider = provider
|
|
331
|
+
let tools = tools
|
|
332
|
+
let asked = messages + [LlamaEngine.Message(role: "user", content: prompt)]
|
|
333
|
+
return AsyncThrowingStream { continuation in
|
|
334
|
+
let cancelled = CancelFlag()
|
|
335
|
+
let task = Task { @MainActor [weak self] in
|
|
336
|
+
do {
|
|
337
|
+
let engine = try await provider.loaded()
|
|
338
|
+
var conversation = asked
|
|
339
|
+
var reply = ""
|
|
340
|
+
for round in 0...Self.toolRounds {
|
|
341
|
+
let offersTools = !tools.isEmpty && round < Self.toolRounds
|
|
342
|
+
reply = try await engine.generate(conversation, options: options, cancelled: cancelled) { text in
|
|
343
|
+
// What may be a tool call is not shown.
|
|
344
|
+
if !offersTools || !Self.mayBeCall(text) { continuation.yield(text) }
|
|
345
|
+
}
|
|
346
|
+
guard offersTools, let call = Self.toolCall(in: reply), let tool = tools.first(where: { $0.name == call.name }) else { break }
|
|
347
|
+
let output: String
|
|
348
|
+
do {
|
|
349
|
+
output = try await tool.call(call.arguments)
|
|
350
|
+
} catch {
|
|
351
|
+
output = "The tool failed: \(error)"
|
|
352
|
+
}
|
|
353
|
+
conversation += [
|
|
354
|
+
LlamaEngine.Message(role: "assistant", content: reply),
|
|
355
|
+
LlamaEngine.Message(role: engine.toolRole, content: output),
|
|
356
|
+
]
|
|
357
|
+
reply = ""
|
|
358
|
+
}
|
|
359
|
+
if !tools.isEmpty, Self.mayBeCall(reply) { continuation.yield(reply) }
|
|
360
|
+
// What the viewer saw is what the conversation goes on from, a stopped reply included.
|
|
361
|
+
if !reply.isEmpty { self?.messages = conversation + [LlamaEngine.Message(role: "assistant", content: reply)] }
|
|
362
|
+
continuation.finish()
|
|
363
|
+
} catch {
|
|
364
|
+
continuation.finish(throwing: error)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
continuation.onTermination = { _ in
|
|
368
|
+
cancelled.cancel()
|
|
369
|
+
task.cancel()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func close() {}
|
|
375
|
+
|
|
376
|
+
/// The instructions, with the tools described after them.
|
|
377
|
+
nonisolated static func systemPrompt(instructions: String?, tools: [LanguageModelTool]) -> String? {
|
|
378
|
+
guard !tools.isEmpty else { return instructions }
|
|
379
|
+
let list = tools.map { tool in
|
|
380
|
+
"- \(tool.name): \(tool.description) Arguments (JSON schema): \(tool.parameters.json)"
|
|
381
|
+
}.joined(separator: "\n")
|
|
382
|
+
let guide = """
|
|
383
|
+
You can use these tools:
|
|
384
|
+
\(list)
|
|
385
|
+
To use a tool, reply with only a JSON object and nothing else: {"tool": "<name>", "arguments": {...}}. \
|
|
386
|
+
You will then get the tool's result and can answer. Use a tool only when it helps; otherwise answer directly.
|
|
387
|
+
"""
|
|
388
|
+
return [instructions, guide].compactMap { $0 }.joined(separator: "\n\n")
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Whether a reply so far could still turn out to be a tool call.
|
|
392
|
+
nonisolated static func mayBeCall(_ text: String) -> Bool {
|
|
393
|
+
let trimmed = text.drop(while: \.isWhitespace)
|
|
394
|
+
return trimmed.hasPrefix("{") || trimmed.hasPrefix("```")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// The tool call a reply makes, if it is one: `{"tool"|"name": …, "arguments"|"parameters": {…}}`,
|
|
398
|
+
/// alone or in a code fence.
|
|
399
|
+
nonisolated static func toolCall(in reply: String) -> (name: String, arguments: JSONValue)? {
|
|
400
|
+
var text = reply.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
401
|
+
if text.hasPrefix("```") {
|
|
402
|
+
text = text.drop(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
403
|
+
if text.hasSuffix("```") { text = String(text.dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
404
|
+
}
|
|
405
|
+
guard text.hasPrefix("{"), let object = (try? JSONValue.parse(text))?.object else { return nil }
|
|
406
|
+
guard let name = (object["tool"] ?? object["name"])?.string else { return nil }
|
|
407
|
+
return (name, object["arguments"] ?? object["parameters"] ?? .object([:]))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// swift-tools-version: 6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// SwiftBrowserShader: the native side of @swiftbrowser/shader. A shader
|
|
5
|
+
// written in the shader language and compiled by the package's TypeScript
|
|
6
|
+
// compiler draws here in an `MTKView`, its Metal compiled when a view first
|
|
7
|
+
// draws it; JavaScript gives it as a `ShaderView`. A package of its own, so
|
|
8
|
+
// only an app that draws with shaders links it; it installs itself as the
|
|
9
|
+
// `SwiftBrowserShader.view` registered view (`SwiftBrowserShader.install()`).
|
|
10
|
+
//
|
|
11
|
+
// SwiftBrowserShaderCore is the view's props alone (the shader's layout, its
|
|
12
|
+
// uniforms packed as the draw reads them, its textures), with no Apple
|
|
13
|
+
// framework, so it builds and tests on Linux too.
|
|
14
|
+
let package = Package(
|
|
15
|
+
name: "SwiftBrowserShader",
|
|
16
|
+
platforms: [.iOS(.v17), .macOS(.v14)],
|
|
17
|
+
products: [
|
|
18
|
+
.library(name: "SwiftBrowserShader", targets: ["SwiftBrowserShader"]),
|
|
19
|
+
.library(name: "SwiftBrowserShaderCore", targets: ["SwiftBrowserShaderCore"]),
|
|
20
|
+
],
|
|
21
|
+
dependencies: [
|
|
22
|
+
.package(path: "../.."),
|
|
23
|
+
],
|
|
24
|
+
targets: [
|
|
25
|
+
.target(name: "SwiftBrowserShaderCore", dependencies: [.product(name: "SwiftBrowserProtocol", package: "swift")]),
|
|
26
|
+
.target(
|
|
27
|
+
name: "SwiftBrowserShader",
|
|
28
|
+
dependencies: ["SwiftBrowserShaderCore", .product(name: "SwiftBrowserNative", package: "swift")]
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
)
|