@happyvertical/smrt-svelte 0.51.11 → 0.51.13
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/AGENTS.md +27 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts +129 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.js +331 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +464 -0
- package/dist/browser-ai/adapters/llm/index.d.ts +2 -0
- package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/index.js +2 -0
- package/dist/browser-ai/adapters/llm/types.d.ts +18 -0
- package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts +67 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.js +364 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +646 -0
- package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm.js +19 -2
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte +190 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts +17 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +172 -0
- package/dist/browser-ai/svelte/index.d.ts +2 -1
- package/dist/browser-ai/svelte/index.d.ts.map +1 -1
- package/dist/browser-ai/svelte/index.js +3 -1
- package/dist/i18n/strings.browser-ai.d.ts +4 -0
- package/dist/i18n/strings.browser-ai.d.ts.map +1 -0
- package/dist/i18n/strings.browser-ai.js +4 -0
- package/package.json +19 -15
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `InferenceBackend` over a browser LLM adapter.
|
|
3
|
+
*
|
|
4
|
+
* This is the local half of the inference path: it exposes whatever the
|
|
5
|
+
* adapter layer can actually do — text generation with load/progress — and
|
|
6
|
+
* refuses, loudly, the shapes it cannot faithfully represent rather than
|
|
7
|
+
* quietly degrading them.
|
|
8
|
+
*
|
|
9
|
+
* ## What the local backend cannot do, and says so
|
|
10
|
+
*
|
|
11
|
+
* `LLMAdapter` (`./types.js`) is a TEXT conversation contract:
|
|
12
|
+
* `LLMMessage.role` is `'system' | 'user' | 'assistant'`, `content` is a
|
|
13
|
+
* `string`, and `LLMChatOptions` carries no `tools`. So this backend runs
|
|
14
|
+
* {@link assertTextOnlyTurn} — the SAME check the route backend runs — and
|
|
15
|
+
* throws `InferencePathError` (`code: 'unsupported_message'`) for a
|
|
16
|
+
* `tool`/`function` message, a message carrying `tool_calls`, non-string
|
|
17
|
+
* content, or any tool definition/choice. Those turns belong on a backend that
|
|
18
|
+
* supports them: a silent rewrite would let a model answer as though it had
|
|
19
|
+
* seen context (or tools) it never received, and the two backends must agree
|
|
20
|
+
* on which turns they accept or a per-call switch changes the answer.
|
|
21
|
+
*
|
|
22
|
+
* `signal` and `timeout` are NOT honored: the adapter has no cancellation
|
|
23
|
+
* seam, so a local generation runs to completion once started. That wastes GPU
|
|
24
|
+
* time but cannot produce a wrong answer, so it degrades rather than throws.
|
|
25
|
+
*
|
|
26
|
+
* ## Adapter ownership
|
|
27
|
+
*
|
|
28
|
+
* Pass `adapter` to share the app-managed one (the warm-client cache and
|
|
29
|
+
* `SmrtAppStateManager.initializeLLM` own a single adapter per
|
|
30
|
+
* `type:model` — `src/state/warm-clients.ts`). Omitting it constructs an
|
|
31
|
+
* unshared adapter via `getLLM()`, which will NOT deduplicate against that
|
|
32
|
+
* cache; this module cannot reach the cache itself, because `state/` depends
|
|
33
|
+
* on `browser-ai/` and not the other way round.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
import { assertTextOnlyTurn, InferencePathError, } from '@happyvertical/smrt-web/ai';
|
|
38
|
+
import { detectCapabilities } from '../../capabilities/detector.js';
|
|
39
|
+
import { getLLM } from './factory.js';
|
|
40
|
+
/** Map an adapter's init state onto the path's backend status. */
|
|
41
|
+
function toBackendStatus(adapter) {
|
|
42
|
+
if (!adapter)
|
|
43
|
+
return 'idle';
|
|
44
|
+
switch (adapter.initState) {
|
|
45
|
+
case 'ready':
|
|
46
|
+
return 'ready';
|
|
47
|
+
case 'initializing':
|
|
48
|
+
return 'loading';
|
|
49
|
+
case 'error':
|
|
50
|
+
return 'error';
|
|
51
|
+
default:
|
|
52
|
+
return 'idle';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Project an already-validated turn into the adapter's message shape.
|
|
57
|
+
*
|
|
58
|
+
* The caller runs {@link assertTextOnlyTurn} first, which is what narrows
|
|
59
|
+
* `messages` to a text turn — this function must not be the place the check
|
|
60
|
+
* lives, or the two backends' rules drift apart.
|
|
61
|
+
*/
|
|
62
|
+
function toAdapterMessages(messages) {
|
|
63
|
+
return messages.map(({ role, content }) => ({ role, content }));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Forward only the options the adapter can honor.
|
|
67
|
+
*
|
|
68
|
+
* `ensuredModel` is the model this backend exists to serve. It WINS over a
|
|
69
|
+
* per-call one, because one engine binds one model: passing a different id
|
|
70
|
+
* would make the adapter unload the model `load()` ensured and download that
|
|
71
|
+
* one behind the first message, answering from a model the caller never chose.
|
|
72
|
+
* A per-call `model` therefore only reaches the adapter when this backend was
|
|
73
|
+
* configured with none — the same constraint bitgpu documents.
|
|
74
|
+
*/
|
|
75
|
+
function toAdapterOptions(options, ensuredModel) {
|
|
76
|
+
const model = ensuredModel ?? options?.model;
|
|
77
|
+
return {
|
|
78
|
+
...(model ? { model } : {}),
|
|
79
|
+
...(options?.maxTokens !== undefined
|
|
80
|
+
? { maxTokens: options.maxTokens }
|
|
81
|
+
: {}),
|
|
82
|
+
...(options?.temperature !== undefined
|
|
83
|
+
? { temperature: options.temperature }
|
|
84
|
+
: {}),
|
|
85
|
+
...(options?.topP !== undefined ? { topP: options.topP } : {}),
|
|
86
|
+
...(options?.stop
|
|
87
|
+
? { stop: Array.isArray(options.stop) ? options.stop : [options.stop] }
|
|
88
|
+
: {}),
|
|
89
|
+
...(options?.onProgress ? { onToken: options.onProgress } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A local backend over a browser LLM adapter.
|
|
94
|
+
*
|
|
95
|
+
* `status` reports `'unavailable'` without WebGPU, so an `auto` path skips it
|
|
96
|
+
* instead of attempting a multi-gigabyte download on a device that cannot run
|
|
97
|
+
* the result. `load()` must be called (by a control, or by the caller) before
|
|
98
|
+
* the backend reports `'ready'`.
|
|
99
|
+
*/
|
|
100
|
+
export function createWebLlmInferenceBackend(options = {}) {
|
|
101
|
+
let adapter = options.adapter ?? null;
|
|
102
|
+
let progress;
|
|
103
|
+
const listeners = new Set();
|
|
104
|
+
let loading = null;
|
|
105
|
+
// The adapter flips `initState` to `'initializing'` only after its own
|
|
106
|
+
// awaits, so relying on that alone would report `'idle'` while a load is
|
|
107
|
+
// genuinely in flight — and `'idle'` is the state auto-selection must skip.
|
|
108
|
+
let loadInFlight = false;
|
|
109
|
+
// Bumped by `unload()`. A load captures it before its first `await` and
|
|
110
|
+
// re-checks it afterwards, so a load that overlaps an `unload()` releases
|
|
111
|
+
// what it loaded instead of publishing `ready` for a backend the caller has
|
|
112
|
+
// already released.
|
|
113
|
+
let loadEpoch = 0;
|
|
114
|
+
// The highest epoch whose initialization has COMPLETED. A superseded attempt
|
|
115
|
+
// consults it (with `loadInFlight`) before releasing the adapter's model:
|
|
116
|
+
// releasing when a newer attempt already owns that model would destroy it.
|
|
117
|
+
let lastPublishedEpoch = -1;
|
|
118
|
+
// The single in-flight load, stamped with the epoch that created it: concurrent
|
|
119
|
+
// callers share one attempt (as bitgpu's memo does), and a caller never joins
|
|
120
|
+
// an attempt from a superseded epoch.
|
|
121
|
+
let loadPromise = null;
|
|
122
|
+
// Set when a superseded attempt establishes a model it cannot release because
|
|
123
|
+
// a newer attempt owns the adapter. That newer attempt's failure discharges
|
|
124
|
+
// it — which is the ONLY case where the failure path may call `unloadModel()`
|
|
125
|
+
// without erasing the adapter's own `error` state.
|
|
126
|
+
let orphanedModel = false;
|
|
127
|
+
// Durable copy of the last load failure. It is read from HERE rather than from
|
|
128
|
+
// the adapter's mutable `initState`, because the release that follows a
|
|
129
|
+
// failure resets that state — and `'idle'` would read as "never loaded".
|
|
130
|
+
let loadError = null;
|
|
131
|
+
function broadcast() {
|
|
132
|
+
for (const listener of [...listeners])
|
|
133
|
+
listener();
|
|
134
|
+
}
|
|
135
|
+
function currentStatus() {
|
|
136
|
+
// WebLLM is the only adapter this backend constructs by default, and it
|
|
137
|
+
// cannot run at all without WebGPU. Report `unavailable` up front rather
|
|
138
|
+
// than `idle`, so nothing tries to load a model that can never work.
|
|
139
|
+
const effectiveType = adapter?.type ?? 'webllm';
|
|
140
|
+
if (effectiveType === 'webllm' && !detectCapabilities().llm.webgpu) {
|
|
141
|
+
return 'unavailable';
|
|
142
|
+
}
|
|
143
|
+
// Checked before the adapter so a failure survives the release that follows
|
|
144
|
+
// it, and before `loadInFlight` so a retry in flight does not hide it.
|
|
145
|
+
if (loadError)
|
|
146
|
+
return 'error';
|
|
147
|
+
if (loadInFlight)
|
|
148
|
+
return 'loading';
|
|
149
|
+
return toBackendStatus(adapter);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* True when the adapter already holds the model this backend ensures. A shared
|
|
153
|
+
* adapter may be `ready` on a DIFFERENT one — the warm-cache case that
|
|
154
|
+
* `options.adapter` exists for — and short-circuiting then would skip a load
|
|
155
|
+
* the caller asked for and hand the turn to the wrong model.
|
|
156
|
+
*/
|
|
157
|
+
function holdsRequestedModel() {
|
|
158
|
+
if (!options.model)
|
|
159
|
+
return true;
|
|
160
|
+
return adapter?.currentModel === options.model;
|
|
161
|
+
}
|
|
162
|
+
async function ensureAdapter() {
|
|
163
|
+
if (adapter)
|
|
164
|
+
return adapter;
|
|
165
|
+
// Concurrent `load()`/`chat()` calls share one construction, so two
|
|
166
|
+
// callers cannot each start an unshared adapter (and its download).
|
|
167
|
+
loading ??= (async () => {
|
|
168
|
+
const { appConfig, loadModule, model } = options;
|
|
169
|
+
const created = await getLLM({
|
|
170
|
+
...(appConfig ? { appConfig } : {}),
|
|
171
|
+
...(loadModule ? { loadModule } : {}),
|
|
172
|
+
// Pin the configured model as the adapter's DEFAULT, not merely the one
|
|
173
|
+
// `load()` names. A turn that does not name one resolves the adapter's
|
|
174
|
+
// default, so without this the adapter would unload the model `load()`
|
|
175
|
+
// ensured and download its own default behind the first message.
|
|
176
|
+
...(model ? { defaultModel: model } : {}),
|
|
177
|
+
});
|
|
178
|
+
adapter = created;
|
|
179
|
+
return created;
|
|
180
|
+
})();
|
|
181
|
+
try {
|
|
182
|
+
return await loading;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
loading = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
id: options.id ?? 'local',
|
|
190
|
+
kind: 'local',
|
|
191
|
+
get status() {
|
|
192
|
+
return currentStatus();
|
|
193
|
+
},
|
|
194
|
+
get progress() {
|
|
195
|
+
return progress;
|
|
196
|
+
},
|
|
197
|
+
load(onProgress) {
|
|
198
|
+
if (currentStatus() === 'unavailable') {
|
|
199
|
+
return Promise.reject(new InferencePathError('WebLLM requires WebGPU, which this browser does not expose.', 'no_usable_backend'));
|
|
200
|
+
}
|
|
201
|
+
// No-op when already ready — the contract `InferenceBackend.load`
|
|
202
|
+
// documents, and the one bitgpu's `load` honors. Without it, a host that
|
|
203
|
+
// follows the documented "call `load()` before a turn" pattern toggles
|
|
204
|
+
// this backend through `loading`, the state auto-selection skips, so a
|
|
205
|
+
// turn issued in that window silently routes to the server instead of the
|
|
206
|
+
// model that is already resident.
|
|
207
|
+
if (currentStatus() === 'ready' && holdsRequestedModel()) {
|
|
208
|
+
return Promise.resolve();
|
|
209
|
+
}
|
|
210
|
+
// One attempt per epoch, shared by concurrent callers — the single-flight
|
|
211
|
+
// bitgpu applies. Without it a second caller reaches the adapter's own
|
|
212
|
+
// `'initializing'` poll, which settles only on `ready` or `error`, so a
|
|
213
|
+
// release landing in between would leave that caller waiting.
|
|
214
|
+
if (loadPromise && loadPromise.epoch === loadEpoch) {
|
|
215
|
+
return loadPromise.promise;
|
|
216
|
+
}
|
|
217
|
+
// A new attempt supersedes the previous failure.
|
|
218
|
+
loadError = null;
|
|
219
|
+
// Marked before the first `await`: a caller that reads `status`
|
|
220
|
+
// immediately after calling `load()` must not see `'idle'`, which is
|
|
221
|
+
// exactly the state auto-selection is told to skip.
|
|
222
|
+
loadInFlight = true;
|
|
223
|
+
broadcast();
|
|
224
|
+
// Captured before the first `await`; every step that publishes state
|
|
225
|
+
// re-checks it, because `unload()` may run while this is in flight.
|
|
226
|
+
const epoch = loadEpoch;
|
|
227
|
+
const pending = (async () => {
|
|
228
|
+
try {
|
|
229
|
+
const target = await ensureAdapter();
|
|
230
|
+
await target.ensureInitialized(options.model, (next) => {
|
|
231
|
+
// A download that outlives an `unload()` must not resurrect progress
|
|
232
|
+
// on a backend the caller has just released.
|
|
233
|
+
if (epoch !== loadEpoch)
|
|
234
|
+
return;
|
|
235
|
+
progress = next;
|
|
236
|
+
onProgress?.(next);
|
|
237
|
+
broadcast();
|
|
238
|
+
});
|
|
239
|
+
if (epoch !== loadEpoch) {
|
|
240
|
+
// `unload()` ran while this attempt was loading, so it must not
|
|
241
|
+
// publish. It may only release the model it established when nothing
|
|
242
|
+
// newer owns it — a later `load()` that is still in flight, or one
|
|
243
|
+
// that has already completed, would otherwise have the model it
|
|
244
|
+
// published destroyed underneath it.
|
|
245
|
+
if (!loadInFlight && lastPublishedEpoch < epoch) {
|
|
246
|
+
await target.unloadModel();
|
|
247
|
+
}
|
|
248
|
+
else if (loadInFlight) {
|
|
249
|
+
// A newer attempt owns the adapter right now, so the model this
|
|
250
|
+
// one established stays resident for now; that newer attempt's
|
|
251
|
+
// failure discharges it. Anything else — a newer attempt that has
|
|
252
|
+
// already PUBLISHED — owns the adapter's one model, so nothing is
|
|
253
|
+
// orphaned and this attempt must not mark it so.
|
|
254
|
+
orphanedModel = true;
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
lastPublishedEpoch = epoch;
|
|
259
|
+
// A published model is owned by this epoch, so nothing is orphaned
|
|
260
|
+
// and no later failure may release it as if it were.
|
|
261
|
+
orphanedModel = false;
|
|
262
|
+
loadError = null;
|
|
263
|
+
progress = undefined;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// Discharge a release a SUPERSEDED attempt deferred: it left a model
|
|
267
|
+
// resident because this attempt owned the adapter, and this attempt
|
|
268
|
+
// then failed, so nothing would ever release it. Scoped to exactly
|
|
269
|
+
// that case — an unconditional `unloadModel()` here would wipe the
|
|
270
|
+
// adapter's own `error` state and report `idle` for a load that
|
|
271
|
+
// failed.
|
|
272
|
+
// Only a CURRENT-epoch failure may discharge it. A superseded
|
|
273
|
+
// attempt's failure says nothing about which attempt owns the adapter
|
|
274
|
+
// now, and releasing on it would destroy the model the current epoch
|
|
275
|
+
// published — with no error recorded and the next turn downloading
|
|
276
|
+
// behind the message.
|
|
277
|
+
if (orphanedModel && epoch === loadEpoch) {
|
|
278
|
+
orphanedModel = false;
|
|
279
|
+
// Best-effort: this path is already failing, and the cleanup must
|
|
280
|
+
// not replace the caller's error with its own.
|
|
281
|
+
await adapter?.unloadModel().catch(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
if (epoch === loadEpoch) {
|
|
284
|
+
// Mirror bitgpu: keep a snapshot of how far the load got, marked as
|
|
285
|
+
// failed rather than left reading `downloading` on an `idle`
|
|
286
|
+
// backend — and record the failure so it survives the release that
|
|
287
|
+
// follows it.
|
|
288
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
289
|
+
loadError = err;
|
|
290
|
+
progress = {
|
|
291
|
+
bytesLoaded: progress?.bytesLoaded ?? 0,
|
|
292
|
+
bytesTotal: progress?.bytesTotal ?? 0,
|
|
293
|
+
percent: progress?.percent ?? 0,
|
|
294
|
+
state: 'error',
|
|
295
|
+
error: err.message,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
// A superseded attempt must not clear the flag for the load that
|
|
302
|
+
// replaced it.
|
|
303
|
+
if (epoch === loadEpoch)
|
|
304
|
+
loadInFlight = false;
|
|
305
|
+
broadcast();
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
loadPromise = { promise: pending, epoch };
|
|
309
|
+
const releaseLoad = () => {
|
|
310
|
+
if (loadPromise?.promise === pending)
|
|
311
|
+
loadPromise = null;
|
|
312
|
+
};
|
|
313
|
+
void pending.then(releaseLoad, releaseLoad);
|
|
314
|
+
return pending;
|
|
315
|
+
},
|
|
316
|
+
async unload() {
|
|
317
|
+
// Invalidate any load in flight: it re-checks the epoch before
|
|
318
|
+
// publishing and releases what it loaded instead of reporting `ready`.
|
|
319
|
+
// The flag is cleared here because that superseded load will no longer
|
|
320
|
+
// clear it, and `'loading'` is the state auto-selection must skip.
|
|
321
|
+
loadEpoch += 1;
|
|
322
|
+
loadInFlight = false;
|
|
323
|
+
loadPromise = null;
|
|
324
|
+
// Ownership does not survive a release: an attempt from an older epoch
|
|
325
|
+
// that finishes later must not read a newer attempt's completion as
|
|
326
|
+
// "someone else owns this model", or it would skip releasing what it
|
|
327
|
+
// re-established and silently undo the caller's `unload()`.
|
|
328
|
+
lastPublishedEpoch = -1;
|
|
329
|
+
orphanedModel = false;
|
|
330
|
+
loadError = null;
|
|
331
|
+
await adapter?.unloadModel();
|
|
332
|
+
progress = undefined;
|
|
333
|
+
broadcast();
|
|
334
|
+
},
|
|
335
|
+
async chat(messages, chatOptions) {
|
|
336
|
+
assertTextOnlyTurn('The browser LLM adapter', messages, chatOptions);
|
|
337
|
+
const target = await ensureAdapter();
|
|
338
|
+
const response = await target.chat(toAdapterMessages(messages), toAdapterOptions(chatOptions, options.model));
|
|
339
|
+
return {
|
|
340
|
+
content: response.content,
|
|
341
|
+
...(response.usage ? { usage: response.usage } : {}),
|
|
342
|
+
...(response.model ? { model: response.model } : {}),
|
|
343
|
+
// The adapter also reports `'error'`, which `AIResponse` has no member
|
|
344
|
+
// for — a failed call is carried by a thrown error on that contract,
|
|
345
|
+
// so a completed response simply has no finish reason here.
|
|
346
|
+
...(response.finishReason === 'stop' ||
|
|
347
|
+
response.finishReason === 'length'
|
|
348
|
+
? { finishReason: response.finishReason }
|
|
349
|
+
: {}),
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
async *stream(messages, chatOptions) {
|
|
353
|
+
assertTextOnlyTurn('The browser LLM adapter', messages, chatOptions);
|
|
354
|
+
const target = await ensureAdapter();
|
|
355
|
+
yield* target.stream(toAdapterMessages(messages), toAdapterOptions(chatOptions, options.model));
|
|
356
|
+
},
|
|
357
|
+
subscribe(listener) {
|
|
358
|
+
listeners.add(listener);
|
|
359
|
+
return () => {
|
|
360
|
+
listeners.delete(listener);
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|