use-browser-llm 0.1.0 → 0.1.2

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 (2) hide show
  1. package/dist/index.js +137 -24
  2. package/package.json +13 -1
package/dist/index.js CHANGED
@@ -1,5 +1,12 @@
1
1
  // src/use-browser-llm.ts
2
- import { useCallback, useEffect, useReducer, useRef, useState } from "react";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useReducer,
7
+ useRef,
8
+ useState
9
+ } from "react";
3
10
 
4
11
  // src/engine-client.ts
5
12
  import * as Comlink from "comlink";
@@ -98,41 +105,58 @@ async function detectWebGPUSupport() {
98
105
  }
99
106
 
100
107
  // src/to-async-generator.ts
101
- async function* toAsyncGenerator(run) {
108
+ async function* toAsyncGenerator(run, signal) {
102
109
  const queue = [];
103
110
  let resolveNext = null;
104
111
  let done = false;
105
112
  let error = null;
113
+ const onAbort = () => {
114
+ if (!done) {
115
+ error = signal?.reason ?? new Error("aborted");
116
+ done = true;
117
+ resolveNext?.();
118
+ resolveNext = null;
119
+ }
120
+ };
121
+ signal?.addEventListener("abort", onAbort);
106
122
  run((token) => {
107
123
  queue.push(token);
108
124
  resolveNext?.();
109
125
  resolveNext = null;
110
126
  }).then(
111
127
  () => {
112
- done = true;
113
- resolveNext?.();
114
- resolveNext = null;
128
+ if (!done) {
129
+ done = true;
130
+ resolveNext?.();
131
+ resolveNext = null;
132
+ }
115
133
  },
116
134
  (err) => {
117
- error = err;
118
- done = true;
119
- resolveNext?.();
120
- resolveNext = null;
135
+ if (!done) {
136
+ error = err;
137
+ done = true;
138
+ resolveNext?.();
139
+ resolveNext = null;
140
+ }
121
141
  }
122
142
  );
123
- while (true) {
124
- while (queue.length > 0) {
125
- yield queue.shift();
126
- }
127
- if (done) {
128
- if (error) {
129
- throw error;
143
+ try {
144
+ while (true) {
145
+ while (queue.length > 0) {
146
+ yield queue.shift();
130
147
  }
131
- return;
148
+ if (done) {
149
+ if (error) {
150
+ throw error;
151
+ }
152
+ return;
153
+ }
154
+ await new Promise((resolve) => {
155
+ resolveNext = resolve;
156
+ });
132
157
  }
133
- await new Promise((resolve) => {
134
- resolveNext = resolve;
135
- });
158
+ } finally {
159
+ signal?.removeEventListener("abort", onAbort);
136
160
  }
137
161
  }
138
162
 
@@ -144,6 +168,8 @@ var initialState = {
144
168
  cacheStatus: "idle"
145
169
  };
146
170
  var LOAD_INACTIVITY_TIMEOUT_MS = 3e4;
171
+ var GENERATE_TIMEOUT_MS = 9e4;
172
+ var STREAM_INACTIVITY_TIMEOUT_MS = 3e4;
147
173
  function reducer(state, action) {
148
174
  switch (action.type) {
149
175
  case "reset":
@@ -179,7 +205,9 @@ function useBrowserLLM(modelId) {
179
205
  const [state, dispatch] = useReducer(reducer, initialState);
180
206
  const clientRef = useRef(null);
181
207
  const statusRef = useRef(state.status);
182
- statusRef.current = state.status;
208
+ useLayoutEffect(() => {
209
+ statusRef.current = state.status;
210
+ }, [state.status]);
183
211
  const isGeneratingRef = useRef(false);
184
212
  const [isGenerating, setIsGenerating] = useState(false);
185
213
  const [generationError, setGenerationError] = useState(null);
@@ -233,6 +261,8 @@ function useBrowserLLM(modelId) {
233
261
  unsubscribeCrash = client.onCrash((error) => {
234
262
  if (!cancelled) {
235
263
  clearWatchdog();
264
+ clientRef.current?.terminate();
265
+ clientRef.current = null;
236
266
  dispatch({ type: "error", error });
237
267
  }
238
268
  });
@@ -281,16 +311,63 @@ function useBrowserLLM(modelId) {
281
311
  if (isGeneratingRef.current) {
282
312
  throw new HookBusyError();
283
313
  }
314
+ const client = clientRef.current;
284
315
  isGeneratingRef.current = true;
285
316
  setIsGenerating(true);
286
317
  setGenerationError(null);
318
+ let settled = false;
319
+ let timeoutId = null;
320
+ const crashSub = {
321
+ unsubscribe: null
322
+ };
287
323
  try {
288
- return await clientRef.current.generate(messages);
324
+ return await new Promise((resolve, reject) => {
325
+ crashSub.unsubscribe = client.onCrash((error) => {
326
+ if (settled) {
327
+ return;
328
+ }
329
+ settled = true;
330
+ reject(error);
331
+ });
332
+ timeoutId = setTimeout(() => {
333
+ if (settled) {
334
+ return;
335
+ }
336
+ settled = true;
337
+ const error = new WorkerCrashError(
338
+ `no response for ${GENERATE_TIMEOUT_MS / 1e3}s \u2014 the worker may have been killed by the browser (e.g. out of memory)`
339
+ );
340
+ if (clientRef.current === client) {
341
+ clientRef.current = null;
342
+ client.terminate();
343
+ dispatch({ type: "error", error });
344
+ }
345
+ reject(error);
346
+ }, GENERATE_TIMEOUT_MS);
347
+ client.generate(messages).then(
348
+ (result) => {
349
+ if (!settled) {
350
+ settled = true;
351
+ resolve(result);
352
+ }
353
+ },
354
+ (err) => {
355
+ if (!settled) {
356
+ settled = true;
357
+ reject(err instanceof Error ? err : new Error(String(err)));
358
+ }
359
+ }
360
+ );
361
+ });
289
362
  } catch (err) {
290
363
  const error = err instanceof Error ? err : new Error(String(err));
291
364
  setGenerationError(error);
292
365
  throw error;
293
366
  } finally {
367
+ if (timeoutId !== null) {
368
+ clearTimeout(timeoutId);
369
+ }
370
+ crashSub.unsubscribe?.();
294
371
  isGeneratingRef.current = false;
295
372
  setIsGenerating(false);
296
373
  }
@@ -304,6 +381,7 @@ function useBrowserLLM(modelId) {
304
381
  isGeneratingRef,
305
382
  setIsGenerating,
306
383
  setGenerationError,
384
+ dispatch,
307
385
  messages
308
386
  ),
309
387
  []
@@ -321,7 +399,7 @@ function useBrowserLLM(modelId) {
321
399
  abort
322
400
  };
323
401
  }
324
- async function* streamGenerateImpl(statusRef, clientRef, isGeneratingRef, setIsGenerating, setGenerationError, messages) {
402
+ async function* streamGenerateImpl(statusRef, clientRef, isGeneratingRef, setIsGenerating, setGenerationError, dispatch, messages) {
325
403
  if (statusRef.current !== "ready" || !clientRef.current) {
326
404
  throw new HookNotReadyError();
327
405
  }
@@ -332,13 +410,48 @@ async function* streamGenerateImpl(statusRef, clientRef, isGeneratingRef, setIsG
332
410
  isGeneratingRef.current = true;
333
411
  setIsGenerating(true);
334
412
  setGenerationError(null);
413
+ const controller = new AbortController();
414
+ let watchdog = null;
415
+ const clearWatchdog = () => {
416
+ if (watchdog !== null) {
417
+ clearTimeout(watchdog);
418
+ watchdog = null;
419
+ }
420
+ };
421
+ const resetWatchdog = () => {
422
+ clearWatchdog();
423
+ watchdog = setTimeout(() => {
424
+ const error = new WorkerCrashError(
425
+ `no token for ${STREAM_INACTIVITY_TIMEOUT_MS / 1e3}s \u2014 the worker may have been killed by the browser (e.g. out of memory)`
426
+ );
427
+ controller.abort(error);
428
+ if (clientRef.current === client) {
429
+ clientRef.current = null;
430
+ client.terminate();
431
+ dispatch({ type: "error", error });
432
+ }
433
+ }, STREAM_INACTIVITY_TIMEOUT_MS);
434
+ };
435
+ const unsubscribeCrash = client.onCrash((error) => {
436
+ controller.abort(error);
437
+ });
335
438
  try {
336
- yield* toAsyncGenerator((onToken) => client.streamGenerate(messages, onToken));
439
+ resetWatchdog();
440
+ const tokens = toAsyncGenerator(
441
+ (onToken) => client.streamGenerate(messages, onToken),
442
+ controller.signal
443
+ );
444
+ for await (const token of tokens) {
445
+ resetWatchdog();
446
+ yield token;
447
+ }
337
448
  } catch (err) {
338
449
  const error = err instanceof Error ? err : new Error(String(err));
339
450
  setGenerationError(error);
340
451
  throw error;
341
452
  } finally {
453
+ clearWatchdog();
454
+ unsubscribeCrash();
342
455
  isGeneratingRef.current = false;
343
456
  setIsGenerating(false);
344
457
  client.abort().catch(() => {
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "use-browser-llm",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Headless React hook for running LLMs locally in the browser via WebGPU, with zero backend and complete privacy.",
5
+ "keywords": [
6
+ "react",
7
+ "react-hooks",
8
+ "llm",
9
+ "ai",
10
+ "webgpu",
11
+ "web-llm",
12
+ "browser",
13
+ "on-device-ai",
14
+ "privacy",
15
+ "offline"
16
+ ],
5
17
  "license": "MIT",
6
18
  "repository": {
7
19
  "type": "git",