@missionsquad/browserai 0.1.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/LICENSE +15 -0
- package/README.md +229 -0
- package/dist/audio/clip.d.ts +51 -0
- package/dist/audio/clip.d.ts.map +1 -0
- package/dist/audio/clip.js +71 -0
- package/dist/audio/clip.js.map +1 -0
- package/dist/audio/decode.d.ts +7 -0
- package/dist/audio/decode.d.ts.map +1 -0
- package/dist/audio/decode.js +19 -0
- package/dist/audio/decode.js.map +1 -0
- package/dist/audio/pcm.d.ts +13 -0
- package/dist/audio/pcm.d.ts.map +1 -0
- package/dist/audio/pcm.js +66 -0
- package/dist/audio/pcm.js.map +1 -0
- package/dist/audio/recorder.d.ts +40 -0
- package/dist/audio/recorder.d.ts.map +1 -0
- package/dist/audio/recorder.js +127 -0
- package/dist/audio/recorder.js.map +1 -0
- package/dist/audio/streaming-player.d.ts +29 -0
- package/dist/audio/streaming-player.d.ts.map +1 -0
- package/dist/audio/streaming-player.js +82 -0
- package/dist/audio/streaming-player.js.map +1 -0
- package/dist/client.d.ts +195 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +638 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +74 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +43 -0
- package/dist/events.js.map +1 -0
- package/dist/generation.d.ts +12 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +30 -0
- package/dist/generation.js.map +1 -0
- package/dist/hardware.d.ts +23 -0
- package/dist/hardware.d.ts.map +1 -0
- package/dist/hardware.js +150 -0
- package/dist/hardware.js.map +1 -0
- package/dist/history.d.ts +81 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +193 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/inference.d.ts +89 -0
- package/dist/inference.d.ts.map +1 -0
- package/dist/inference.js +411 -0
- package/dist/inference.js.map +1 -0
- package/dist/json.d.ts +22 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +145 -0
- package/dist/json.js.map +1 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +59 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model-cache.d.ts +49 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +511 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-source.d.ts +27 -0
- package/dist/model-source.d.ts.map +1 -0
- package/dist/model-source.js +110 -0
- package/dist/model-source.js.map +1 -0
- package/dist/models.d.ts +103 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +1052 -0
- package/dist/models.js.map +1 -0
- package/dist/multimodal.d.ts +261 -0
- package/dist/multimodal.d.ts.map +1 -0
- package/dist/multimodal.js +506 -0
- package/dist/multimodal.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +32 -0
- package/dist/prompt.js.map +1 -0
- package/dist/proxy-verify.d.ts +10 -0
- package/dist/proxy-verify.d.ts.map +1 -0
- package/dist/proxy-verify.js +98 -0
- package/dist/proxy-verify.js.map +1 -0
- package/dist/schema-validator.d.ts +6 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +104 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema.d.ts +98 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +115 -0
- package/dist/schema.js.map +1 -0
- package/dist/text-utils.d.ts +8 -0
- package/dist/text-utils.d.ts.map +1 -0
- package/dist/text-utils.js +14 -0
- package/dist/text-utils.js.map +1 -0
- package/dist/voice/helpers.d.ts +35 -0
- package/dist/voice/helpers.d.ts.map +1 -0
- package/dist/voice/helpers.js +49 -0
- package/dist/voice/helpers.js.map +1 -0
- package/dist/voice/voice-pipeline.d.ts +155 -0
- package/dist/voice/voice-pipeline.d.ts.map +1 -0
- package/dist/voice/voice-pipeline.js +407 -0
- package/dist/voice/voice-pipeline.js.map +1 -0
- package/dist/webllm.worker.d.ts +2 -0
- package/dist/webllm.worker.d.ts.map +1 -0
- package/dist/webllm.worker.js +8 -0
- package/dist/webllm.worker.js.map +1 -0
- package/package.json +62 -0
- package/src/audio/clip.ts +94 -0
- package/src/audio/decode.ts +19 -0
- package/src/audio/pcm.ts +62 -0
- package/src/audio/recorder.ts +146 -0
- package/src/audio/streaming-player.ts +99 -0
- package/src/client.ts +784 -0
- package/src/config.ts +104 -0
- package/src/errors.ts +81 -0
- package/src/events.ts +53 -0
- package/src/generation.ts +39 -0
- package/src/hardware.ts +193 -0
- package/src/history.ts +257 -0
- package/src/index.ts +203 -0
- package/src/inference.ts +579 -0
- package/src/json.ts +169 -0
- package/src/metrics.ts +63 -0
- package/src/model-cache.ts +637 -0
- package/src/model-source.ts +142 -0
- package/src/models.ts +1171 -0
- package/src/multimodal.ts +697 -0
- package/src/prompt.ts +33 -0
- package/src/proxy-verify.ts +113 -0
- package/src/schema-validator.ts +117 -0
- package/src/schema.ts +118 -0
- package/src/text-utils.ts +12 -0
- package/src/voice/helpers.ts +59 -0
- package/src/voice/voice-pipeline.ts +513 -0
- package/src/webllm.worker.ts +9 -0
- package/worker-template/README.md +39 -0
- package/worker-template/cloudflare-worker.ts +389 -0
- package/worker-template/wrangler.jsonc +19 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { getModelPreset, MODEL_PRESETS, modelRepoLabel, modelSlots } from "./models.js";
|
|
2
|
+
import { EMPTY_RESPONSE, generate } from "./inference.js";
|
|
3
|
+
import { clampNumber, runtimeParamsFromPreset } from "./generation.js";
|
|
4
|
+
import { describeImage as describeImageWithHandle, disposeMultimodal, generateGemmaText, loadMultimodal, supportsStreamingTts, synthesize as synthesizeWithHandle, synthesizeStream, transcribe as transcribeWithHandle, } from "./multimodal.js";
|
|
5
|
+
import { DEFAULT_CONTEXT_LENGTH, resolveConfig } from "./config.js";
|
|
6
|
+
import { applyContextOverride, buildWebLLMAppConfig, TRANSFORMERS_CACHE_KEY } from "./model-source.js";
|
|
7
|
+
import { verifyTransformersProxy, verifyWebLLMProxy } from "./proxy-verify.js";
|
|
8
|
+
import { collectHardwareSnapshot } from "./hardware.js";
|
|
9
|
+
import { deleteOneModelArtifactsFromBrowserStorage, deleteTransformersModelArtifactsFromBrowserStorage, deleteWebLLMModelArtifactsFromBrowserStorage, estimateOriginStorage, getCachedModelStatus, } from "./model-cache.js";
|
|
10
|
+
import { buildMultimodalStats, runRecordFromStats } from "./metrics.js";
|
|
11
|
+
import { RunHistoryStore } from "./history.js";
|
|
12
|
+
import { TypedEmitter } from "./events.js";
|
|
13
|
+
import { BrowserAIError, MissingModelError, ModelLoadError, UnknownModelError, WebGPUUnavailableError } from "./errors.js";
|
|
14
|
+
import { synthesizeClip } from "./audio/clip.js";
|
|
15
|
+
/**
|
|
16
|
+
* Multimodal runtimes whose loaders honor a wasm device. The VLM/Gemma/audio-LLM recipes hardcode
|
|
17
|
+
* WebGPU (their verified recipes only run there), so they cannot fall back.
|
|
18
|
+
*/
|
|
19
|
+
const WASM_CAPABLE_RUNTIMES = new Set(["stt", "tts-pipeline", "tts-kokoro"]);
|
|
20
|
+
/**
|
|
21
|
+
* The BrowserAI client: model catalog + capability-slot manager + inference façade.
|
|
22
|
+
*
|
|
23
|
+
* Models load into one of four capability slots — text / vision / stt / tts — with at most one
|
|
24
|
+
* model per slot (up to four resident at once). Loading a model whose slots conflict with loaded
|
|
25
|
+
* models replaces the conflicting owners. Loads/unloads are single-flight, and runs are serialized
|
|
26
|
+
* per loaded model, so concurrent calls queue instead of driving one engine from two places.
|
|
27
|
+
*/
|
|
28
|
+
export class BrowserAI extends TypedEmitter {
|
|
29
|
+
config;
|
|
30
|
+
#loaded = [];
|
|
31
|
+
#lifecycleBusy = false;
|
|
32
|
+
#runChains = new WeakMap();
|
|
33
|
+
#runsInFlight = 0;
|
|
34
|
+
#hardware = null;
|
|
35
|
+
#history = null;
|
|
36
|
+
constructor(config = {}) {
|
|
37
|
+
super();
|
|
38
|
+
this.config = resolveConfig(config);
|
|
39
|
+
}
|
|
40
|
+
/* ------------------------------------------------------------------ */
|
|
41
|
+
/* Catalog + slots */
|
|
42
|
+
/* ------------------------------------------------------------------ */
|
|
43
|
+
/** The full model catalog. */
|
|
44
|
+
get presets() {
|
|
45
|
+
return MODEL_PRESETS;
|
|
46
|
+
}
|
|
47
|
+
/** Catalog lookup without fallback. */
|
|
48
|
+
getPreset(modelId) {
|
|
49
|
+
return MODEL_PRESETS.find((preset) => preset.id === modelId);
|
|
50
|
+
}
|
|
51
|
+
/** Currently loaded models (read-only snapshot). */
|
|
52
|
+
get loadedModels() {
|
|
53
|
+
return this.#loaded;
|
|
54
|
+
}
|
|
55
|
+
/** The loaded model occupying `task`, if any (≤1 owner per slot — enforced at load). */
|
|
56
|
+
slotOwner(task) {
|
|
57
|
+
return this.#loaded.find((model) => model.slots.includes(task));
|
|
58
|
+
}
|
|
59
|
+
textModel() {
|
|
60
|
+
return this.slotOwner("text");
|
|
61
|
+
}
|
|
62
|
+
visionModel() {
|
|
63
|
+
return this.slotOwner("vision");
|
|
64
|
+
}
|
|
65
|
+
sttModel() {
|
|
66
|
+
return this.slotOwner("stt");
|
|
67
|
+
}
|
|
68
|
+
ttsModel() {
|
|
69
|
+
return this.slotOwner("tts");
|
|
70
|
+
}
|
|
71
|
+
/** Every capability slot currently filled by a loaded model. */
|
|
72
|
+
occupiedSlots() {
|
|
73
|
+
const filled = new Set();
|
|
74
|
+
for (const model of this.#loaded) {
|
|
75
|
+
for (const slot of model.slots)
|
|
76
|
+
filled.add(slot);
|
|
77
|
+
}
|
|
78
|
+
return filled;
|
|
79
|
+
}
|
|
80
|
+
/** Whether `preset` can load without evicting anything (all its slots are free). */
|
|
81
|
+
isLoadable(preset) {
|
|
82
|
+
const occupied = this.occupiedSlots();
|
|
83
|
+
return modelSlots(preset).every((slot) => !occupied.has(slot));
|
|
84
|
+
}
|
|
85
|
+
/** True while a load/unload/delete is in flight or any generation run is executing. */
|
|
86
|
+
isBusy() {
|
|
87
|
+
return this.#lifecycleBusy || this.#runsInFlight > 0;
|
|
88
|
+
}
|
|
89
|
+
/** True while a load/unload/cache-delete lifecycle operation is in flight. */
|
|
90
|
+
get loading() {
|
|
91
|
+
return this.#lifecycleBusy;
|
|
92
|
+
}
|
|
93
|
+
/** The most recent hardware probe (from the last load), if any. */
|
|
94
|
+
get lastHardwareSnapshot() {
|
|
95
|
+
return this.#hardware;
|
|
96
|
+
}
|
|
97
|
+
/** Probe WebGPU + hardware now (also refreshes {@link lastHardwareSnapshot}). */
|
|
98
|
+
async probeHardware() {
|
|
99
|
+
this.#hardware = await collectHardwareSnapshot();
|
|
100
|
+
return this.#hardware;
|
|
101
|
+
}
|
|
102
|
+
/** The run log configured by `config.history` (created lazily; localStorage-backed by default). */
|
|
103
|
+
get history() {
|
|
104
|
+
this.#history ??= new RunHistoryStore(this.config.history);
|
|
105
|
+
return this.#history;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Map a run's stats to a {@link RunRecord} and append it to {@link history}. Returns the record,
|
|
109
|
+
* or null for stats without a model (error placeholders are never logged).
|
|
110
|
+
*/
|
|
111
|
+
logRun(caseKey, stats) {
|
|
112
|
+
const record = runRecordFromStats(caseKey, stats);
|
|
113
|
+
if (record)
|
|
114
|
+
this.history.append(record);
|
|
115
|
+
return record;
|
|
116
|
+
}
|
|
117
|
+
/* ------------------------------------------------------------------ */
|
|
118
|
+
/* Load / unload */
|
|
119
|
+
/* ------------------------------------------------------------------ */
|
|
120
|
+
/**
|
|
121
|
+
* Load a model by catalog id. Evicts any loaded models occupying the slots this one needs
|
|
122
|
+
* (whole-model eviction: a multi-slot owner is fully released even for a one-slot conflict).
|
|
123
|
+
* Single-flight: a second load while one is in flight throws.
|
|
124
|
+
*
|
|
125
|
+
* @throws {UnknownModelError} for an id not in the catalog.
|
|
126
|
+
* @throws {WebGPUUnavailableError} when WebGPU is required and unavailable. Only ASR-pipeline and
|
|
127
|
+
* TTS presets fall back to wasm; text, vision, Gemma and audio-LLM presets require WebGPU.
|
|
128
|
+
* @throws {ModelLoadError} wrapping any backend load failure.
|
|
129
|
+
*/
|
|
130
|
+
async load(modelId, options = {}) {
|
|
131
|
+
const preset = this.getPreset(modelId);
|
|
132
|
+
if (!preset)
|
|
133
|
+
throw new UnknownModelError(modelId);
|
|
134
|
+
if (this.#lifecycleBusy)
|
|
135
|
+
throw new BrowserAIError("Another load/unload is already in progress.");
|
|
136
|
+
this.#lifecycleBusy = true; // claimed synchronously before any await (protects ≤1-per-slot)
|
|
137
|
+
try {
|
|
138
|
+
this.#hardware = await collectHardwareSnapshot();
|
|
139
|
+
let device = "webgpu";
|
|
140
|
+
if (!this.#hardware.webgpuSupported) {
|
|
141
|
+
// Only the runtimes whose loaders honor a wasm device (ASR pipelines, TTS) can fall back;
|
|
142
|
+
// VLM/Gemma/audio-LLM recipes hardcode WebGPU, and text engines require it outright.
|
|
143
|
+
if (preset.mmRuntime && WASM_CAPABLE_RUNTIMES.has(preset.mmRuntime))
|
|
144
|
+
device = "wasm";
|
|
145
|
+
else
|
|
146
|
+
throw new WebGPUUnavailableError(this.#hardware.webgpuReason);
|
|
147
|
+
}
|
|
148
|
+
// Free any loaded models occupying the slots this one needs, waiting out their in-flight runs.
|
|
149
|
+
const wanted = modelSlots(preset);
|
|
150
|
+
const conflicts = this.#loaded.filter((model) => model.slots.some((slot) => wanted.includes(slot)));
|
|
151
|
+
for (const conflict of conflicts) {
|
|
152
|
+
await this.#runExclusive(conflict, () => this.#releaseModel(conflict));
|
|
153
|
+
}
|
|
154
|
+
const started = performance.now();
|
|
155
|
+
const progress = (info) => {
|
|
156
|
+
const payload = { modelId: preset.id, ...info };
|
|
157
|
+
this.emit("loadprogress", payload);
|
|
158
|
+
options.onProgress?.(payload);
|
|
159
|
+
};
|
|
160
|
+
let model;
|
|
161
|
+
try {
|
|
162
|
+
if (preset.mmRuntime) {
|
|
163
|
+
model = await this.#loadMultimodalModel(preset, device, progress);
|
|
164
|
+
}
|
|
165
|
+
else if (preset.backend === "transformers-js") {
|
|
166
|
+
model = await this.#loadTransformersModel(preset, progress);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
model = await this.#loadWebLLMModel(preset, options, progress);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw error instanceof BrowserAIError ? error : new ModelLoadError(preset.id, error);
|
|
174
|
+
}
|
|
175
|
+
this.#loaded.push(model);
|
|
176
|
+
this.#status(`Loaded ${preset.shortName} in ${((performance.now() - started) / 1000).toFixed(1)}s.`);
|
|
177
|
+
this.emit("modelloaded", { model });
|
|
178
|
+
return model;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
this.#lifecycleBusy = false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Unload one model, waiting out its in-flight runs. No-op when the id is not loaded. */
|
|
185
|
+
async unload(modelId) {
|
|
186
|
+
const model = this.#loaded.find((entry) => entry.modelId === modelId);
|
|
187
|
+
if (!model)
|
|
188
|
+
return;
|
|
189
|
+
if (this.#lifecycleBusy)
|
|
190
|
+
throw new BrowserAIError("Another load/unload is already in progress.");
|
|
191
|
+
this.#lifecycleBusy = true;
|
|
192
|
+
try {
|
|
193
|
+
await this.#runExclusive(model, () => this.#releaseModel(model));
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.#lifecycleBusy = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Unload every loaded model. */
|
|
200
|
+
async unloadAll() {
|
|
201
|
+
if (this.#lifecycleBusy)
|
|
202
|
+
throw new BrowserAIError("Another load/unload is already in progress.");
|
|
203
|
+
this.#lifecycleBusy = true;
|
|
204
|
+
try {
|
|
205
|
+
await this.#releaseAll();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
this.#lifecycleBusy = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async #releaseAll() {
|
|
212
|
+
for (const model of [...this.#loaded]) {
|
|
213
|
+
await this.#runExclusive(model, () => this.#releaseModel(model));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Tear an engine down: WebLLM unload, Transformers dispose, multimodal dispose, worker terminate. */
|
|
217
|
+
async #releaseModel(model) {
|
|
218
|
+
try {
|
|
219
|
+
try {
|
|
220
|
+
await model.engine?.unload();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Best-effort: unload can reject after WebGPU device loss / a crashed worker. The worker
|
|
224
|
+
// still gets terminated below and the tab releases the GPU context regardless.
|
|
225
|
+
}
|
|
226
|
+
await disposeTransformersGenerator(model.transformersGenerator);
|
|
227
|
+
await disposeMultimodal(model.multimodal);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
model.worker?.terminate(); // never leak the worker, even when unload rejected
|
|
231
|
+
this.#loaded = this.#loaded.filter((entry) => entry !== model); // always drop from the slot table
|
|
232
|
+
this.emit("modelunloaded", { modelId: model.modelId });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/* ------------------------------------------------------------------ */
|
|
236
|
+
/* Backend loaders */
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
#modelSourceMode() {
|
|
239
|
+
return this.config.modelSource === "proxy" ? "same-origin-proxy" : "direct";
|
|
240
|
+
}
|
|
241
|
+
#requireProxyOrigin() {
|
|
242
|
+
const origin = this.config.proxyOrigin;
|
|
243
|
+
if (!origin) {
|
|
244
|
+
throw new BrowserAIError('modelSource is "proxy" but no proxyOrigin is configured and no page origin is available.');
|
|
245
|
+
}
|
|
246
|
+
return origin;
|
|
247
|
+
}
|
|
248
|
+
async #loadWebLLMModel(preset, options, progress) {
|
|
249
|
+
this.#status(`Loading ${preset.shortName}… First load downloads and caches the model, so it can take a while.`);
|
|
250
|
+
const webllm = await import("@mlc-ai/web-llm");
|
|
251
|
+
const mode = this.#modelSourceMode();
|
|
252
|
+
const proxyOrigin = mode === "same-origin-proxy" ? this.#requireProxyOrigin() : "https://invalid.example";
|
|
253
|
+
let appConfig = buildWebLLMAppConfig(webllm, mode, proxyOrigin, this.config.cacheBackend);
|
|
254
|
+
appConfig = applyContextOverride(appConfig, preset.id, normalizeContextLength(options.contextLength));
|
|
255
|
+
if (mode === "same-origin-proxy" && this.config.verifyProxy) {
|
|
256
|
+
this.#status("Checking model proxy…");
|
|
257
|
+
await verifyWebLLMProxy(proxyOrigin, preset.id, appConfig);
|
|
258
|
+
}
|
|
259
|
+
const engineConfig = {
|
|
260
|
+
appConfig,
|
|
261
|
+
logLevel: this.config.webllm.logLevel ?? "INFO",
|
|
262
|
+
initProgressCallback: (report) => {
|
|
263
|
+
progress({ progress: Number.isFinite(report.progress) ? report.progress : 0, status: report.text || `Loading ${preset.shortName}…` });
|
|
264
|
+
this.#status(report.text || `Loading ${preset.shortName}…`);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
let engine;
|
|
268
|
+
let worker = null;
|
|
269
|
+
if (this.config.webllm.worker) {
|
|
270
|
+
worker = this.config.webllm.worker();
|
|
271
|
+
try {
|
|
272
|
+
engine = await webllm.CreateWebWorkerMLCEngine(worker, preset.id, engineConfig);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
worker.terminate(); // don't leak the worker if engine creation fails
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
engine = await webllm.CreateMLCEngine(preset.id, engineConfig);
|
|
281
|
+
}
|
|
282
|
+
progress({ progress: 1 });
|
|
283
|
+
return { ...newLoadedModel(preset), engine, worker };
|
|
284
|
+
}
|
|
285
|
+
async #loadTransformersModel(preset, progress) {
|
|
286
|
+
this.#status(`Loading ${preset.shortName} with Transformers.js/WebGPU…`);
|
|
287
|
+
progress({ progress: 0.05 });
|
|
288
|
+
const transformers = (await import("@huggingface/transformers"));
|
|
289
|
+
this.#configureTransformersEnvironment(transformers);
|
|
290
|
+
if (this.#modelSourceMode() === "same-origin-proxy" && this.config.verifyProxy) {
|
|
291
|
+
this.#status("Checking Transformers.js model proxy…");
|
|
292
|
+
await verifyTransformersProxy(this.#requireProxyOrigin(), preset.id);
|
|
293
|
+
}
|
|
294
|
+
const generator = await transformers.pipeline("text-generation", preset.id, {
|
|
295
|
+
dtype: preset.transformersDtype ?? "q4",
|
|
296
|
+
device: "webgpu",
|
|
297
|
+
progress_callback: this.#makeTransformersProgressForwarder(progress),
|
|
298
|
+
});
|
|
299
|
+
progress({ progress: 1 });
|
|
300
|
+
return {
|
|
301
|
+
...newLoadedModel(preset),
|
|
302
|
+
transformersGenerator: generator,
|
|
303
|
+
transformersTokenizer: generator.tokenizer ?? null,
|
|
304
|
+
transformersModule: transformers,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async #loadMultimodalModel(preset, device, progress) {
|
|
308
|
+
this.#status(`Loading ${preset.shortName} with Transformers.js/WebGPU…`);
|
|
309
|
+
progress({ progress: 0.05 });
|
|
310
|
+
const transformers = (await import("@huggingface/transformers"));
|
|
311
|
+
this.#configureTransformersEnvironment(transformers);
|
|
312
|
+
// Kokoro (kokoro-js) bundles its own runtime and fetches weights directly; others use the proxy.
|
|
313
|
+
if (this.#modelSourceMode() === "same-origin-proxy" && this.config.verifyProxy && preset.mmRuntime !== "tts-kokoro") {
|
|
314
|
+
this.#status("Checking Transformers.js model proxy…");
|
|
315
|
+
await verifyTransformersProxy(this.#requireProxyOrigin(), preset.id);
|
|
316
|
+
}
|
|
317
|
+
const handle = await loadMultimodal(preset, transformers, device, this.#makeTransformersProgressForwarder(progress), {
|
|
318
|
+
kokoro: this.config.kokoro,
|
|
319
|
+
});
|
|
320
|
+
progress({ progress: 1 });
|
|
321
|
+
return { ...newLoadedModel(preset), multimodal: handle, mmModule: transformers };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Adapt Transformers.js progress callbacks (percentages 0–100 interleaved with status-only
|
|
325
|
+
* initiate/download/done events) to the SDK's 0–1 loadprogress. Status-only events re-emit the
|
|
326
|
+
* last numeric value instead of snapping the reported progress back to zero.
|
|
327
|
+
*/
|
|
328
|
+
#makeTransformersProgressForwarder(progress) {
|
|
329
|
+
let lastProgress = 0;
|
|
330
|
+
return (info) => {
|
|
331
|
+
const report = info;
|
|
332
|
+
const label = [report.status, report.file].filter(Boolean).join(" ");
|
|
333
|
+
if (typeof report.progress === "number") {
|
|
334
|
+
lastProgress = Math.max(0, Math.min(1, report.progress / 100));
|
|
335
|
+
progress({ progress: lastProgress, status: report.status, file: report.file });
|
|
336
|
+
}
|
|
337
|
+
else if (label) {
|
|
338
|
+
progress({ progress: lastProgress, status: report.status, file: report.file });
|
|
339
|
+
}
|
|
340
|
+
if (label)
|
|
341
|
+
this.#status(label);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
#configureTransformersEnvironment(transformers) {
|
|
345
|
+
const env = transformers.env;
|
|
346
|
+
if (!env)
|
|
347
|
+
return;
|
|
348
|
+
env.allowLocalModels = false;
|
|
349
|
+
env.allowRemoteModels = true;
|
|
350
|
+
env.useBrowserCache = true;
|
|
351
|
+
env.useFSCache = false;
|
|
352
|
+
env.cacheKey = TRANSFORMERS_CACHE_KEY;
|
|
353
|
+
if (this.#modelSourceMode() === "same-origin-proxy") {
|
|
354
|
+
env.remoteHost = `${this.#requireProxyOrigin()}/hf-transformers/`;
|
|
355
|
+
env.remotePathTemplate = "{model}/resolve/{revision}/";
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
env.remoteHost = "https://huggingface.co/";
|
|
359
|
+
env.remotePathTemplate = "{model}/resolve/{revision}/";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/* ------------------------------------------------------------------ */
|
|
363
|
+
/* Inference façade */
|
|
364
|
+
/* ------------------------------------------------------------------ */
|
|
365
|
+
/**
|
|
366
|
+
* Run one text generation against the loaded Text-slot model. A Gemma-4 multimodal handle has no
|
|
367
|
+
* WebLLM engine or Transformers.js LM pipeline, so it routes through the Gemma text path;
|
|
368
|
+
* everything else goes to the shared backend-agnostic generator. Runtime parameters default to
|
|
369
|
+
* the loaded model's preset defaults, with `options.runtime` merged on top.
|
|
370
|
+
*/
|
|
371
|
+
async generateText(messages, options = {}) {
|
|
372
|
+
const model = this.textModel();
|
|
373
|
+
if (!model)
|
|
374
|
+
throw new MissingModelError("text");
|
|
375
|
+
const preset = getModelPreset(model.modelId);
|
|
376
|
+
const runtime = { ...runtimeParamsFromPreset(preset.defaultRuntime), ...options.runtime };
|
|
377
|
+
return this.#runExclusive(model, async () => {
|
|
378
|
+
const handle = model.multimodal;
|
|
379
|
+
if (handle && handle.kind === "gemma") {
|
|
380
|
+
const gen = await generateGemmaText(handle, messages, {
|
|
381
|
+
maxNewTokens: runtime.maxTokens,
|
|
382
|
+
temperature: runtime.temperature,
|
|
383
|
+
topP: runtime.topP,
|
|
384
|
+
repetitionPenalty: runtime.repetitionPenalty,
|
|
385
|
+
onDelta: options.onDelta,
|
|
386
|
+
});
|
|
387
|
+
const stats = buildMultimodalStats(gen, model.modelId);
|
|
388
|
+
// Surface likely truncation the same way the Transformers.js path does.
|
|
389
|
+
stats.finishReason = gen.completionTokens >= runtime.maxTokens ? "length" : "stop";
|
|
390
|
+
return { text: gen.text || EMPTY_RESPONSE, stats };
|
|
391
|
+
}
|
|
392
|
+
return generate(engineHandles(model), messages, runtime, preset, {
|
|
393
|
+
schema: options.schema,
|
|
394
|
+
onDelta: options.onDelta,
|
|
395
|
+
resetChat: options.resetChat,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** Reset the WebLLM KV cache of the loaded text model (e.g. for a "new chat"). Queues behind in-flight runs. */
|
|
400
|
+
async resetTextChat() {
|
|
401
|
+
const model = this.textModel();
|
|
402
|
+
if (!model?.engine)
|
|
403
|
+
return;
|
|
404
|
+
await this.#runExclusive(model, async () => {
|
|
405
|
+
await model.engine?.resetChat(false);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/** Transcribe 16 kHz mono PCM (or a URL) with the loaded Transcription-slot model. */
|
|
409
|
+
async transcribe(audio, onDelta) {
|
|
410
|
+
const model = this.sttModel();
|
|
411
|
+
if (!model?.multimodal)
|
|
412
|
+
throw new MissingModelError("stt");
|
|
413
|
+
const handle = model.multimodal;
|
|
414
|
+
return this.#runExclusive(model, () => transcribeWithHandle(handle, audio, onDelta));
|
|
415
|
+
}
|
|
416
|
+
/** Synthesize speech with the loaded Speech-slot model. Returns the raw model audio. */
|
|
417
|
+
async synthesize(text, overrides = {}) {
|
|
418
|
+
const model = this.ttsModel();
|
|
419
|
+
if (!model?.multimodal)
|
|
420
|
+
throw new MissingModelError("tts");
|
|
421
|
+
const handle = model.multimodal;
|
|
422
|
+
const { voice, speed } = { ...this.config.tts, ...overrides };
|
|
423
|
+
return this.#runExclusive(model, () => synthesizeWithHandle(handle, text, { voice, speed }));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Synthesize speech and package it as a playable clip (pitch baked into the blob, original PCM
|
|
427
|
+
* kept for re-pitching). Voice/speed/pitch default to the client's TTS config.
|
|
428
|
+
*/
|
|
429
|
+
async synthesizeToClip(text, overrides = {}) {
|
|
430
|
+
const model = this.ttsModel();
|
|
431
|
+
if (!model?.multimodal)
|
|
432
|
+
throw new MissingModelError("tts");
|
|
433
|
+
const handle = model.multimodal;
|
|
434
|
+
const { voice, speed, pitch } = { ...this.config.tts, ...overrides };
|
|
435
|
+
return this.#runExclusive(model, () => synthesizeClip(handle, text, { voice, speed, pitch, createUrl: overrides.createUrl }));
|
|
436
|
+
}
|
|
437
|
+
/** Whether the loaded Speech-slot model supports sentence-streaming synthesis (Kokoro). */
|
|
438
|
+
supportsStreamingTts() {
|
|
439
|
+
return supportsStreamingTts(this.ttsModel()?.multimodal);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Begin a streaming TTS session on the loaded Kokoro model: push LLM deltas into `splitter`,
|
|
443
|
+
* consume `chunks` (one RawAudio per sentence), then call `release()` when done. The session
|
|
444
|
+
* holds the TTS model's serialization lock until released so no other speech run interleaves —
|
|
445
|
+
* the returned promise resolves only once the lock is actually held (queued behind in-flight
|
|
446
|
+
* speech runs), and the Kokoro stream is created after that point.
|
|
447
|
+
* @throws {MissingModelError} when no Speech model is loaded or it cannot stream.
|
|
448
|
+
*/
|
|
449
|
+
async createStreamingTts(overrides = {}) {
|
|
450
|
+
const model = this.ttsModel();
|
|
451
|
+
const handle = model?.multimodal;
|
|
452
|
+
if (!model || !supportsStreamingTts(handle)) {
|
|
453
|
+
throw new MissingModelError("tts", "Streaming TTS requires a loaded Kokoro speech model.");
|
|
454
|
+
}
|
|
455
|
+
const { voice, speed } = { ...this.config.tts, ...overrides };
|
|
456
|
+
let release;
|
|
457
|
+
const gate = new Promise((resolve) => {
|
|
458
|
+
release = resolve;
|
|
459
|
+
});
|
|
460
|
+
// Occupy the model's run chain for the session's lifetime (released by release()), and wait
|
|
461
|
+
// until the chain actually reaches this session before touching the engine.
|
|
462
|
+
const acquired = new Promise((resolve) => {
|
|
463
|
+
void this.#runExclusive(model, () => {
|
|
464
|
+
resolve();
|
|
465
|
+
return gate;
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
await acquired;
|
|
469
|
+
const { splitter, chunks } = synthesizeStream(handle, { voice, speed });
|
|
470
|
+
let released = false;
|
|
471
|
+
return {
|
|
472
|
+
splitter,
|
|
473
|
+
chunks,
|
|
474
|
+
release: () => {
|
|
475
|
+
if (released)
|
|
476
|
+
return;
|
|
477
|
+
released = true;
|
|
478
|
+
release();
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** Ask a question about an image with the loaded Image-slot model (VLM or Gemma). */
|
|
483
|
+
async describeImage(imageSrc, prompt, onDelta) {
|
|
484
|
+
const model = this.visionModel();
|
|
485
|
+
if (!model?.multimodal)
|
|
486
|
+
throw new MissingModelError("vision");
|
|
487
|
+
const handle = model.multimodal;
|
|
488
|
+
return this.#runExclusive(model, () => describeImageWithHandle(handle, imageSrc, prompt, onDelta));
|
|
489
|
+
}
|
|
490
|
+
/* ------------------------------------------------------------------ */
|
|
491
|
+
/* Cache management */
|
|
492
|
+
/* ------------------------------------------------------------------ */
|
|
493
|
+
/** Whether a model's artifacts are present in browser storage (Cache API / IndexedDB). */
|
|
494
|
+
async cacheStatus(modelId) {
|
|
495
|
+
const preset = this.getPreset(modelId);
|
|
496
|
+
if (!preset)
|
|
497
|
+
throw new UnknownModelError(modelId);
|
|
498
|
+
return getCachedModelStatus(buildModelCacheTarget(preset));
|
|
499
|
+
}
|
|
500
|
+
/** Origin-wide storage usage/quota estimate (browser-rounded). */
|
|
501
|
+
estimateStorage() {
|
|
502
|
+
return estimateOriginStorage();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Delete one model's downloaded artifacts. If the model is loaded it is released first (its
|
|
506
|
+
* in-flight runs are awaited). Targets model artifact storage only.
|
|
507
|
+
*/
|
|
508
|
+
async deleteModelArtifacts(modelId) {
|
|
509
|
+
const preset = this.getPreset(modelId);
|
|
510
|
+
if (!preset)
|
|
511
|
+
throw new UnknownModelError(modelId);
|
|
512
|
+
if (this.#lifecycleBusy)
|
|
513
|
+
throw new BrowserAIError("Another load/unload is already in progress.");
|
|
514
|
+
this.#lifecycleBusy = true;
|
|
515
|
+
try {
|
|
516
|
+
const loaded = this.#loaded.find((entry) => entry.modelId === modelId);
|
|
517
|
+
if (loaded)
|
|
518
|
+
await this.#runExclusive(loaded, () => this.#releaseModel(loaded));
|
|
519
|
+
return await deleteOneModelArtifactsFromBrowserStorage(buildModelCacheTarget(preset));
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
this.#lifecycleBusy = false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** Delete ALL downloaded model artifacts (WebLLM + Transformers.js), releasing loaded models first. */
|
|
526
|
+
async deleteAllModelArtifacts() {
|
|
527
|
+
if (this.#lifecycleBusy)
|
|
528
|
+
throw new BrowserAIError("Another load/unload is already in progress.");
|
|
529
|
+
this.#lifecycleBusy = true;
|
|
530
|
+
try {
|
|
531
|
+
// Bracket BOTH deletions with the storage estimate so freedBytes reflects the Transformers.js
|
|
532
|
+
// cache too (the WebLLM helper's own after-estimate runs before that deletion happens).
|
|
533
|
+
const before = await estimateOriginStorage();
|
|
534
|
+
await this.#releaseAll();
|
|
535
|
+
const webllmResult = await deleteWebLLMModelArtifactsFromBrowserStorage();
|
|
536
|
+
const transformersScope = await deleteTransformersModelArtifactsFromBrowserStorage();
|
|
537
|
+
await new Promise((resolve) => setTimeout(resolve, 250)); // let the estimate settle
|
|
538
|
+
const after = await estimateOriginStorage();
|
|
539
|
+
const freedBytes = typeof before?.usage === "number" && typeof after?.usage === "number" ? Math.max(0, before.usage - after.usage) : undefined;
|
|
540
|
+
return { before, after, freedBytes, scopes: [...webllmResult.scopes, transformersScope] };
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
this.#lifecycleBusy = false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/* ------------------------------------------------------------------ */
|
|
547
|
+
/* Internals */
|
|
548
|
+
/* ------------------------------------------------------------------ */
|
|
549
|
+
/** Serialize work per loaded model: a run (or teardown) waits for prior runs on the same model. */
|
|
550
|
+
#runExclusive(model, fn) {
|
|
551
|
+
const prior = this.#runChains.get(model) ?? Promise.resolve();
|
|
552
|
+
const run = prior.then(async () => {
|
|
553
|
+
this.#runsInFlight += 1;
|
|
554
|
+
try {
|
|
555
|
+
return await fn();
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
this.#runsInFlight -= 1;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
this.#runChains.set(model, run.then(() => undefined, () => undefined));
|
|
562
|
+
return run;
|
|
563
|
+
}
|
|
564
|
+
#status(message) {
|
|
565
|
+
this.emit("status", { message });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** A blank LoadedModel record for `preset` (the backend-specific loader fills in its runtime handles). */
|
|
569
|
+
function newLoadedModel(preset) {
|
|
570
|
+
return {
|
|
571
|
+
modelId: preset.id,
|
|
572
|
+
slots: modelSlots(preset),
|
|
573
|
+
backend: preset.backend,
|
|
574
|
+
engine: null,
|
|
575
|
+
worker: null,
|
|
576
|
+
transformersGenerator: null,
|
|
577
|
+
transformersTokenizer: null,
|
|
578
|
+
transformersModule: null,
|
|
579
|
+
multimodal: null,
|
|
580
|
+
mmModule: null,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function engineHandles(model) {
|
|
584
|
+
return {
|
|
585
|
+
engine: model.engine,
|
|
586
|
+
transformersGenerator: model.transformersGenerator,
|
|
587
|
+
transformersTokenizer: model.transformersTokenizer,
|
|
588
|
+
transformersModule: model.transformersModule,
|
|
589
|
+
backend: model.backend,
|
|
590
|
+
modelId: model.modelId,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/** Best-effort teardown for a Transformers.js text pipeline (API shape varies across versions). */
|
|
594
|
+
async function disposeTransformersGenerator(generator) {
|
|
595
|
+
if (!generator)
|
|
596
|
+
return;
|
|
597
|
+
try {
|
|
598
|
+
if (typeof generator.dispose === "function") {
|
|
599
|
+
await generator.dispose();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (generator.model && typeof generator.model.dispose === "function") {
|
|
603
|
+
await generator.model.dispose();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// Best-effort cleanup; closing the tab releases the WebGPU context regardless.
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Normalize a context-length override: null/undefined, non-positive, or exactly the tested default
|
|
612
|
+
* ({@link DEFAULT_CONTEXT_LENGTH}) mean "no override"; anything else clamps to [512, 32768].
|
|
613
|
+
*/
|
|
614
|
+
export function normalizeContextLength(value) {
|
|
615
|
+
if (value === null || value === undefined)
|
|
616
|
+
return null;
|
|
617
|
+
if (!Number.isFinite(value) || value <= 0 || value === DEFAULT_CONTEXT_LENGTH)
|
|
618
|
+
return null;
|
|
619
|
+
return clampNumber(value, 512, 32768);
|
|
620
|
+
}
|
|
621
|
+
/** The cache-target matchers (id, repo labels, artifact URLs) for a preset's downloaded artifacts. */
|
|
622
|
+
export function buildModelCacheTarget(preset) {
|
|
623
|
+
const repoLabels = new Set([preset.id, modelRepoLabel(preset)]);
|
|
624
|
+
for (const repoUrl of [preset.officialRepo, preset.artifactRepo].filter(Boolean)) {
|
|
625
|
+
try {
|
|
626
|
+
repoLabels.add(new URL(repoUrl).pathname.replace(/^\//, ""));
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// Ignore malformed optional repo URLs.
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
modelId: preset.id,
|
|
634
|
+
repoLabels: Array.from(repoLabels),
|
|
635
|
+
artifactUrls: [preset.officialRepo, preset.artifactRepo, preset.id].filter(Boolean),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
//# sourceMappingURL=client.js.map
|