@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.
Files changed (27) hide show
  1. package/AGENTS.md +27 -0
  2. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts +129 -0
  3. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts.map +1 -0
  4. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.js +331 -0
  5. package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +464 -0
  6. package/dist/browser-ai/adapters/llm/index.d.ts +2 -0
  7. package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -1
  8. package/dist/browser-ai/adapters/llm/index.js +2 -0
  9. package/dist/browser-ai/adapters/llm/types.d.ts +18 -0
  10. package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -1
  11. package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts +67 -0
  12. package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts.map +1 -0
  13. package/dist/browser-ai/adapters/llm/webllm-inference-backend.js +364 -0
  14. package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +646 -0
  15. package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -1
  16. package/dist/browser-ai/adapters/llm/webllm.js +19 -2
  17. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte +190 -0
  18. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts +17 -0
  19. package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts.map +1 -0
  20. package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +172 -0
  21. package/dist/browser-ai/svelte/index.d.ts +2 -1
  22. package/dist/browser-ai/svelte/index.d.ts.map +1 -1
  23. package/dist/browser-ai/svelte/index.js +3 -1
  24. package/dist/i18n/strings.browser-ai.d.ts +4 -0
  25. package/dist/i18n/strings.browser-ai.d.ts.map +1 -0
  26. package/dist/i18n/strings.browser-ai.js +4 -0
  27. 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
+ }