@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,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
|
+
}
|