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.
- package/dist/index.js +137 -24
- package/package.json +13 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// src/use-browser-llm.ts
|
|
2
|
-
import {
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
if (!done) {
|
|
129
|
+
done = true;
|
|
130
|
+
resolveNext?.();
|
|
131
|
+
resolveNext = null;
|
|
132
|
+
}
|
|
115
133
|
},
|
|
116
134
|
(err) => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
if (!done) {
|
|
136
|
+
error = err;
|
|
137
|
+
done = true;
|
|
138
|
+
resolveNext?.();
|
|
139
|
+
resolveNext = null;
|
|
140
|
+
}
|
|
121
141
|
}
|
|
122
142
|
);
|
|
123
|
-
|
|
124
|
-
while (
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|