@tryhamster/gerbil 1.0.0-rc.9 → 1.0.1
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 +1 -1
- package/README.md +318 -104
- package/dist/architectures-C1I5V3Dt.mjs +6070 -0
- package/dist/architectures-C1I5V3Dt.mjs.map +1 -0
- package/dist/browser/index.d.ts +276 -590
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +592 -2334
- package/dist/browser/index.js.map +1 -1
- package/dist/cli.mjs +625 -1098
- package/dist/cli.mjs.map +1 -1
- package/dist/defaults-9komdrbY.mjs +24 -0
- package/dist/defaults-9komdrbY.mjs.map +1 -0
- package/dist/frameworks/express.d.mts +1 -3
- package/dist/frameworks/express.d.mts.map +1 -1
- package/dist/frameworks/express.mjs +7 -7
- package/dist/frameworks/express.mjs.map +1 -1
- package/dist/frameworks/fastify.d.mts +1 -1
- package/dist/frameworks/fastify.d.mts.map +1 -1
- package/dist/frameworks/fastify.mjs +3 -3
- package/dist/frameworks/fastify.mjs.map +1 -1
- package/dist/frameworks/hono.d.mts +1 -1
- package/dist/frameworks/hono.d.mts.map +1 -1
- package/dist/frameworks/hono.mjs +4 -4
- package/dist/frameworks/hono.mjs.map +1 -1
- package/dist/frameworks/next.d.mts +3 -2
- package/dist/frameworks/next.d.mts.map +1 -1
- package/dist/frameworks/next.mjs +4 -4
- package/dist/frameworks/next.mjs.map +1 -1
- package/dist/frameworks/react.d.mts +1 -1
- package/dist/frameworks/trpc.d.mts +1 -1
- package/dist/frameworks/trpc.d.mts.map +1 -1
- package/dist/frameworks/trpc.mjs +4 -4
- package/dist/frameworks/trpc.mjs.map +1 -1
- package/dist/gerbil-BetB5xb0.d.mts +488 -0
- package/dist/gerbil-BetB5xb0.d.mts.map +1 -0
- package/dist/gerbil-CTZUa8EZ.mjs +4 -0
- package/dist/gerbil-DNniplr4.mjs +1656 -0
- package/dist/gerbil-DNniplr4.mjs.map +1 -0
- package/dist/gpu/hooks.d.mts +640 -0
- package/dist/gpu/hooks.d.mts.map +1 -0
- package/dist/gpu/hooks.mjs +1369 -0
- package/dist/gpu/hooks.mjs.map +1 -0
- package/dist/gpu/index.d.mts +2 -0
- package/dist/gpu/index.mjs +6 -0
- package/dist/gpu-DFuglcEx.mjs +3790 -0
- package/dist/gpu-DFuglcEx.mjs.map +1 -0
- package/dist/index-Dgmb2kE3.d.mts +245 -0
- package/dist/index-Dgmb2kE3.d.mts.map +1 -0
- package/dist/index-DukkJRMj.d.mts +2114 -0
- package/dist/index-DukkJRMj.d.mts.map +1 -0
- package/dist/index.d.mts +22 -487
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +13 -8
- package/dist/index.mjs.map +1 -1
- package/dist/indexeddb-store-BWIMtxxH.mjs +103 -0
- package/dist/indexeddb-store-BWIMtxxH.mjs.map +1 -0
- package/dist/indexeddb-store-ClH12Xnl.mjs +4 -0
- package/dist/integrations/ai-sdk.d.mts +75 -6
- package/dist/integrations/ai-sdk.d.mts.map +1 -1
- package/dist/integrations/ai-sdk.mjs +131 -15
- package/dist/integrations/ai-sdk.mjs.map +1 -1
- package/dist/integrations/langchain.d.mts +1 -1
- package/dist/integrations/langchain.d.mts.map +1 -1
- package/dist/integrations/langchain.mjs +5 -5
- package/dist/integrations/langchain.mjs.map +1 -1
- package/dist/integrations/llamaindex.d.mts +1 -1
- package/dist/integrations/llamaindex.d.mts.map +1 -1
- package/dist/integrations/llamaindex.mjs +5 -5
- package/dist/integrations/llamaindex.mjs.map +1 -1
- package/dist/integrations/mcp-client.mjs +3 -3
- package/dist/integrations/mcp-client.mjs.map +1 -1
- package/dist/integrations/mcp.d.mts +3 -2
- package/dist/integrations/mcp.d.mts.map +1 -1
- package/dist/integrations/mcp.mjs +5 -5
- package/dist/{mcp-BvbriaBy.mjs → mcp-D2vvH1Xc.mjs} +4 -4
- package/dist/mcp-D2vvH1Xc.mjs.map +1 -0
- package/dist/memory/index.d.mts +3 -0
- package/dist/memory/index.mjs +6 -0
- package/dist/memory-D1P7Tmda.mjs +4 -0
- package/dist/memory-DVN0MnIG.mjs +132 -0
- package/dist/memory-DVN0MnIG.mjs.map +1 -0
- package/dist/memory-Dj0J1v88.mjs +294 -0
- package/dist/memory-Dj0J1v88.mjs.map +1 -0
- package/dist/moonshine-stt-17dpP1kr.mjs +4 -0
- package/dist/moonshine-stt-4ojLtMq7.mjs +11962 -0
- package/dist/moonshine-stt-4ojLtMq7.mjs.map +1 -0
- package/dist/{one-liner-s-lD8rCC.mjs → one-liner-JhdIPxzF.mjs} +14 -16
- package/dist/one-liner-JhdIPxzF.mjs.map +1 -0
- package/dist/repl-BDRkwPGX.mjs +9 -0
- package/dist/skills/index.d.mts +270 -320
- package/dist/skills/index.d.mts.map +1 -1
- package/dist/skills/index.mjs +5 -5
- package/dist/{skills-CD3Orlex.mjs → skills-CU694Dc8.mjs} +187 -32
- package/dist/skills-CU694Dc8.mjs.map +1 -0
- package/dist/{tools-Bi1P7Xoy.mjs → tools-DQ1mPUw5.mjs} +34 -22
- package/dist/tools-DQ1mPUw5.mjs.map +1 -0
- package/dist/types-DQBe2lFo.d.mts +165 -0
- package/dist/types-DQBe2lFo.d.mts.map +1 -0
- package/dist/{types-CiTc7ez3.d.mts → types-LlyYILII.d.mts} +112 -14
- package/dist/types-LlyYILII.d.mts.map +1 -0
- package/dist/{utils-CZBZ8dgR.mjs → utils-DKO55ZmZ.mjs} +1 -1
- package/dist/{utils-CZBZ8dgR.mjs.map → utils-DKO55ZmZ.mjs.map} +1 -1
- package/dist/vector-B0panuy6.mjs +95 -0
- package/dist/vector-B0panuy6.mjs.map +1 -0
- package/docs/PROJECT-STATE.md +321 -0
- package/docs/adding-a-model-family.md +280 -0
- package/docs/ai-sdk.md +70 -61
- package/docs/architecture/overview.md +17 -7
- package/docs/browser.md +203 -8
- package/docs/embeddings.md +156 -0
- package/docs/gerbil-site-native-migration.md +217 -0
- package/docs/gpu-engine/architectures.md +398 -0
- package/docs/gpu-engine/ir.md +372 -0
- package/docs/gpu-engine/kernels.md +718 -0
- package/docs/gpu-engine/paper.html +1759 -0
- package/docs/gpu-engine/paper.md +2109 -0
- package/docs/gpu-engine/safetensors.md +312 -0
- package/docs/gpu-engine/tokenizer.md +302 -0
- package/docs/memory-rag.md +91 -0
- package/docs/metal-safari-intel.md +190 -0
- package/docs/mobile-failure-diagnosis.md +124 -0
- package/docs/mobile.md +99 -0
- package/docs/observability.md +230 -0
- package/docs/onnx-removal-plan.md +339 -0
- package/docs/research/autoresearch-portable.md +904 -0
- package/docs/research/dispatch-reduction-hivemind.md +84 -0
- package/docs/research/ios-safari-model-caching.md +117 -0
- package/docs/research/mobile-webgpu-speed-fusion.md +135 -0
- package/docs/research/native-stt-model-selection.md +49 -0
- package/docs/research/native-tts-model-selection.md +90 -0
- package/docs/research/native-vs-chromium-decision.md +152 -0
- package/docs/research/nemotron-mamba2-inference.md +910 -0
- package/docs/research/qwen35-multimodal.md +293 -0
- package/docs/research/qwen36-gemma4-targets.md +337 -0
- package/docs/research/sota-embedding-models.md +179 -0
- package/docs/research/sota-mobile-models-2026.md +263 -0
- package/docs/research/sota-modality-models.md +202 -0
- package/docs/research/tps-baselines.md +71 -0
- package/docs/research/webgpu-m4-reference.md +104 -0
- package/docs/site-update-plan.md +155 -0
- package/docs/structured-output.md +123 -0
- package/docs/stt.md +63 -446
- package/docs/tts.md +77 -499
- package/docs/vision.md +100 -338
- package/package.json +22 -7
- package/dist/chrome-backend-CORwaIyC.mjs +0 -1212
- package/dist/chrome-backend-CORwaIyC.mjs.map +0 -1
- package/dist/chrome-backend-DIKYoWj-.mjs +0 -3
- package/dist/gerbil-CJ3ifloF.mjs +0 -4
- package/dist/gerbil-Dw4Qj77e.mjs +0 -1631
- package/dist/gerbil-Dw4Qj77e.mjs.map +0 -1
- package/dist/gerbil-qOTe1nl2.d.mts +0 -431
- package/dist/gerbil-qOTe1nl2.d.mts.map +0 -1
- package/dist/kokoro-BNTb6egA.mjs +0 -20210
- package/dist/kokoro-BNTb6egA.mjs.map +0 -1
- package/dist/kokoro-CMOGDSgT.js +0 -20212
- package/dist/kokoro-CMOGDSgT.js.map +0 -1
- package/dist/mcp-BvbriaBy.mjs.map +0 -1
- package/dist/one-liner-s-lD8rCC.mjs.map +0 -1
- package/dist/repl-DveXw36T.mjs +0 -9
- package/dist/skills-CD3Orlex.mjs.map +0 -1
- package/dist/stt-Bu-E23Sc.js +0 -433
- package/dist/stt-Bu-E23Sc.js.map +0 -1
- package/dist/stt-CpLYbGFd.mjs +0 -433
- package/dist/stt-CpLYbGFd.mjs.map +0 -1
- package/dist/stt-DRPLEEHB.mjs +0 -3
- package/dist/tools-Bi1P7Xoy.mjs.map +0 -1
- package/dist/transformers.web-DiD1gTwk.js +0 -44695
- package/dist/transformers.web-DiD1gTwk.js.map +0 -1
- package/dist/transformers.web-u34VxRFM.js +0 -3
- package/dist/tts-CqroPaSK.js +0 -724
- package/dist/tts-CqroPaSK.js.map +0 -1
- package/dist/tts-DXgsKGCe.mjs +0 -3
- package/dist/tts-DeGANMNV.mjs +0 -730
- package/dist/tts-DeGANMNV.mjs.map +0 -1
- package/dist/types-CiTc7ez3.d.mts.map +0 -1
- /package/dist/{auto-update-S9s5-g0C.mjs → auto-update-BVaLXcDE.mjs} +0 -0
- /package/dist/{chunk-CkXuGtQK.mjs → chunk-B9cbKln6.mjs} +0 -0
- /package/dist/{microphone-DaMZFRuR.mjs → microphone-Bqmoz9_K.mjs} +0 -0
package/dist/browser/index.js
CHANGED
|
@@ -1,1586 +1,159 @@
|
|
|
1
1
|
//#region src/core/models.ts
|
|
2
2
|
const BUILTIN_MODELS = {
|
|
3
|
-
"qwen3-0.
|
|
4
|
-
id: "qwen3-0.
|
|
5
|
-
repo: "
|
|
6
|
-
description: "Qwen3 0.
|
|
7
|
-
size: "~
|
|
8
|
-
contextLength:
|
|
3
|
+
"qwen3.5-0.8b": {
|
|
4
|
+
id: "qwen3.5-0.8b",
|
|
5
|
+
repo: "Qwen/Qwen3.5-0.8B",
|
|
6
|
+
description: "Qwen3.5 0.8B - Fast, multimodal (vision), 262k context, supports thinking (default)",
|
|
7
|
+
size: "~1.6GB",
|
|
8
|
+
contextLength: 262144,
|
|
9
9
|
supportsThinking: true,
|
|
10
10
|
supportsJson: true,
|
|
11
|
+
supportsVision: true,
|
|
11
12
|
family: "qwen"
|
|
12
13
|
},
|
|
13
|
-
"
|
|
14
|
-
id: "
|
|
15
|
-
repo: "
|
|
16
|
-
description: "
|
|
17
|
-
size: "~
|
|
18
|
-
contextLength: 32768,
|
|
19
|
-
supportsThinking: false,
|
|
20
|
-
supportsJson: true,
|
|
21
|
-
family: "qwen"
|
|
22
|
-
},
|
|
23
|
-
"qwen2.5-coder-0.5b": {
|
|
24
|
-
id: "qwen2.5-coder-0.5b",
|
|
25
|
-
repo: "onnx-community/Qwen2.5-Coder-0.5B-Instruct",
|
|
26
|
-
description: "Qwen2.5 Coder 0.5B - Optimized for code",
|
|
27
|
-
size: "~400MB",
|
|
28
|
-
contextLength: 32768,
|
|
29
|
-
supportsThinking: false,
|
|
30
|
-
supportsJson: true,
|
|
31
|
-
family: "qwen"
|
|
32
|
-
},
|
|
33
|
-
"smollm2-360m": {
|
|
34
|
-
id: "smollm2-360m",
|
|
35
|
-
repo: "HuggingFaceTB/SmolLM2-360M-Instruct",
|
|
36
|
-
description: "SmolLM2 360M - Fast, good for simple tasks",
|
|
37
|
-
size: "~250MB",
|
|
38
|
-
contextLength: 8192,
|
|
39
|
-
supportsThinking: false,
|
|
40
|
-
supportsJson: false,
|
|
41
|
-
family: "smollm"
|
|
42
|
-
},
|
|
43
|
-
"smollm2-135m": {
|
|
44
|
-
id: "smollm2-135m",
|
|
45
|
-
repo: "HuggingFaceTB/SmolLM2-135M-Instruct",
|
|
46
|
-
description: "SmolLM2 135M - Fastest, basic generation",
|
|
47
|
-
size: "~100MB",
|
|
48
|
-
contextLength: 8192,
|
|
49
|
-
supportsThinking: false,
|
|
50
|
-
supportsJson: false,
|
|
51
|
-
family: "smollm"
|
|
52
|
-
},
|
|
53
|
-
"phi-3-mini": {
|
|
54
|
-
id: "phi-3-mini",
|
|
55
|
-
repo: "microsoft/Phi-3-mini-4k-instruct-onnx",
|
|
56
|
-
description: "Phi-3 Mini - High quality, larger model",
|
|
57
|
-
size: "~2.1GB",
|
|
58
|
-
contextLength: 4096,
|
|
59
|
-
supportsThinking: false,
|
|
60
|
-
supportsJson: true,
|
|
61
|
-
family: "phi"
|
|
62
|
-
},
|
|
63
|
-
"ministral-3b": {
|
|
64
|
-
id: "ministral-3b",
|
|
65
|
-
repo: "mistralai/Ministral-3-3B-Instruct-2512-ONNX",
|
|
66
|
-
description: "Ministral 3 3B - Vision + Reasoning, 256k context",
|
|
67
|
-
size: "~2.5GB",
|
|
14
|
+
"qwen3.5-2b": {
|
|
15
|
+
id: "qwen3.5-2b",
|
|
16
|
+
repo: "Qwen/Qwen3.5-2B",
|
|
17
|
+
description: "Qwen3.5 2B - Higher quality, multimodal (vision), 262k context, supports thinking",
|
|
18
|
+
size: "~4GB",
|
|
68
19
|
contextLength: 262144,
|
|
69
20
|
supportsThinking: true,
|
|
70
21
|
supportsJson: true,
|
|
71
22
|
supportsVision: true,
|
|
72
|
-
|
|
73
|
-
|
|
23
|
+
family: "qwen"
|
|
24
|
+
},
|
|
25
|
+
"lfm2.5-1.2b-thinking": {
|
|
26
|
+
id: "lfm2.5-1.2b-thinking",
|
|
27
|
+
repo: "LiquidAI/LFM2.5-1.2B-Thinking",
|
|
28
|
+
description: "LFM2.5 1.2B Thinking - Efficient reasoning model, 128k context",
|
|
29
|
+
size: "~2.4GB",
|
|
30
|
+
contextLength: 128e3,
|
|
31
|
+
supportsThinking: true,
|
|
32
|
+
supportsJson: false,
|
|
33
|
+
family: "other"
|
|
74
34
|
}
|
|
75
35
|
};
|
|
76
|
-
/**
|
|
77
|
-
* Parse model identifier and resolve to source
|
|
78
|
-
*
|
|
79
|
-
* Supported formats:
|
|
80
|
-
* - "qwen3-0.6b" (built-in)
|
|
81
|
-
* - "hf:org/model" (HuggingFace shorthand)
|
|
82
|
-
* - "https://huggingface.co/org/model" (full URL)
|
|
83
|
-
* - "file:./path/to/model" (local path)
|
|
84
|
-
*/
|
|
85
|
-
function resolveModel(modelId) {
|
|
86
|
-
if (BUILTIN_MODELS[modelId]) return {
|
|
87
|
-
type: "builtin",
|
|
88
|
-
path: BUILTIN_MODELS[modelId].repo
|
|
89
|
-
};
|
|
90
|
-
if (modelId.startsWith("hf:")) return {
|
|
91
|
-
type: "huggingface",
|
|
92
|
-
path: modelId.slice(3)
|
|
93
|
-
};
|
|
94
|
-
if (modelId.startsWith("https://huggingface.co/")) return {
|
|
95
|
-
type: "huggingface",
|
|
96
|
-
path: modelId.replace("https://huggingface.co/", "")
|
|
97
|
-
};
|
|
98
|
-
if (modelId.startsWith("file:")) return {
|
|
99
|
-
type: "local",
|
|
100
|
-
path: modelId.slice(5)
|
|
101
|
-
};
|
|
102
|
-
if (modelId.includes("/")) return {
|
|
103
|
-
type: "huggingface",
|
|
104
|
-
path: modelId
|
|
105
|
-
};
|
|
106
|
-
return {
|
|
107
|
-
type: "huggingface",
|
|
108
|
-
path: modelId
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
36
|
|
|
112
37
|
//#endregion
|
|
113
|
-
//#region src/browser/
|
|
38
|
+
//#region src/browser/pwa.ts
|
|
114
39
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* Run LLMs directly in the browser with WebGPU acceleration.
|
|
118
|
-
*
|
|
119
|
-
* @example useChat (React)
|
|
120
|
-
* ```tsx
|
|
121
|
-
* import { useChat } from "@tryhamster/gerbil/browser";
|
|
122
|
-
*
|
|
123
|
-
* function Chat() {
|
|
124
|
-
* const { messages, input, setInput, handleSubmit, isLoading } = useChat();
|
|
125
|
-
*
|
|
126
|
-
* if (isLoading) return <div>Loading model...</div>;
|
|
40
|
+
* Mobile / PWA storage helpers.
|
|
127
41
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* <input value={input} onChange={e => setInput(e.target.value)} />
|
|
132
|
-
* </form>
|
|
133
|
-
* );
|
|
134
|
-
* }
|
|
135
|
-
* ```
|
|
136
|
-
*
|
|
137
|
-
* @example useCompletion (React)
|
|
138
|
-
* ```tsx
|
|
139
|
-
* import { useCompletion } from "@tryhamster/gerbil/browser";
|
|
42
|
+
* On-device models are large (a 4-bit 0.8B is ~400 MB; vision/larger models are
|
|
43
|
+
* GBs). Mobile browsers — iOS Safari especially — wall a web origin off from the
|
|
44
|
+
* real disk with TWO independent ceilings:
|
|
140
45
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* ```
|
|
46
|
+
* 1. **Storage quota** (disk for the model cache). An *uninstalled* Safari tab
|
|
47
|
+
* gets only ~1 GB, best-effort and evictable, regardless of how much free
|
|
48
|
+
* disk the device has. Exceed it and every cache write fails → the model
|
|
49
|
+
* re-downloads on every visit.
|
|
50
|
+
* 2. **Tab memory** (RAM during load/inference) — a separate, smaller ceiling.
|
|
147
51
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
52
|
+
* The unlock for the storage ceiling is **persistent storage**, which iOS Safari
|
|
53
|
+
* grants when the site is **installed to the Home Screen** (a PWA). Installed, the
|
|
54
|
+
* quota jumps to a large fraction of actual disk and is never evicted — so models
|
|
55
|
+
* cache once and stay. These helpers let an app surface that to its users and
|
|
56
|
+
* request it, so on-device AI is actually practical on mobile.
|
|
151
57
|
*
|
|
152
|
-
*
|
|
153
|
-
* modelId: "qwen3-0.6b",
|
|
154
|
-
* onToken: (token) => console.log(token.text),
|
|
155
|
-
* });
|
|
156
|
-
* await gerbil.generate("Hello!");
|
|
157
|
-
* gerbil.terminate();
|
|
158
|
-
* ```
|
|
58
|
+
* All functions are SSR/Node-safe (guarded; return conservative defaults).
|
|
159
59
|
*/
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
async function createGerbilWorker(options = {}) {
|
|
167
|
-
const { modelId = "qwen3-0.6b", onProgress, onToken, onComplete, onError } = options;
|
|
168
|
-
const source = resolveModel(modelId);
|
|
169
|
-
return new Promise((resolve, reject) => {
|
|
170
|
-
const blob = new Blob([`
|
|
171
|
-
import {
|
|
172
|
-
AutoTokenizer,
|
|
173
|
-
AutoModelForCausalLM,
|
|
174
|
-
AutoProcessor,
|
|
175
|
-
AutoModelForImageTextToText,
|
|
176
|
-
RawImage,
|
|
177
|
-
TextStreamer,
|
|
178
|
-
InterruptableStoppingCriteria,
|
|
179
|
-
env,
|
|
180
|
-
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1";
|
|
181
|
-
|
|
182
|
-
// Enable IndexedDB caching for browser (prevents re-downloading models)
|
|
183
|
-
env.useBrowserCache = true;
|
|
184
|
-
env.allowLocalModels = false;
|
|
185
|
-
|
|
186
|
-
class ModelPipeline {
|
|
187
|
-
static tokenizer = null;
|
|
188
|
-
static model = null;
|
|
189
|
-
static processor = null;
|
|
190
|
-
static visionModel = null;
|
|
191
|
-
static modelId = "";
|
|
192
|
-
static isVision = false;
|
|
193
|
-
|
|
194
|
-
static async getInstance(modelId, options = {}, progressCallback) {
|
|
195
|
-
if (this.modelId !== modelId) {
|
|
196
|
-
this.tokenizer = null;
|
|
197
|
-
this.model = null;
|
|
198
|
-
this.processor = null;
|
|
199
|
-
this.visionModel = null;
|
|
200
|
-
}
|
|
201
|
-
this.modelId = modelId;
|
|
202
|
-
|
|
203
|
-
// Detect vision models
|
|
204
|
-
this.isVision = options.vision ||
|
|
205
|
-
modelId.toLowerCase().includes("ministral") ||
|
|
206
|
-
modelId.toLowerCase().includes("vision") ||
|
|
207
|
-
modelId.toLowerCase().includes("vlm");
|
|
208
|
-
|
|
209
|
-
const dtype = options.dtype || "q4f16";
|
|
210
|
-
const device = options.device || "webgpu";
|
|
211
|
-
|
|
212
|
-
if (this.isVision) {
|
|
213
|
-
// Load vision model components
|
|
214
|
-
// Note: Don't specify dtype for vision models - let transformers.js pick defaults
|
|
215
|
-
if (!this.processor) {
|
|
216
|
-
this.processor = await AutoProcessor.from_pretrained(modelId, {
|
|
217
|
-
progress_callback: progressCallback,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
if (!this.visionModel) {
|
|
221
|
-
this.visionModel = await AutoModelForImageTextToText.from_pretrained(modelId, {
|
|
222
|
-
device,
|
|
223
|
-
progress_callback: progressCallback,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
return {
|
|
227
|
-
processor: this.processor,
|
|
228
|
-
model: this.visionModel,
|
|
229
|
-
tokenizer: this.processor.tokenizer,
|
|
230
|
-
isVision: true
|
|
231
|
-
};
|
|
232
|
-
} else {
|
|
233
|
-
// Load text-only model components
|
|
234
|
-
if (!this.tokenizer) {
|
|
235
|
-
this.tokenizer = await AutoTokenizer.from_pretrained(modelId, {
|
|
236
|
-
progress_callback: progressCallback,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
if (!this.model) {
|
|
240
|
-
this.model = await AutoModelForCausalLM.from_pretrained(modelId, {
|
|
241
|
-
dtype,
|
|
242
|
-
device,
|
|
243
|
-
progress_callback: progressCallback,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
return {
|
|
247
|
-
tokenizer: this.tokenizer,
|
|
248
|
-
model: this.model,
|
|
249
|
-
isVision: false
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const stoppingCriteria = new InterruptableStoppingCriteria();
|
|
256
|
-
let pastKeyValuesCache = null;
|
|
257
|
-
|
|
258
|
-
async function load(data) {
|
|
259
|
-
const { modelId, options = {} } = data;
|
|
260
|
-
self.postMessage({ status: "loading", message: "Loading model..." });
|
|
261
|
-
|
|
262
|
-
const downloadState = {
|
|
263
|
-
downloading: new Set(),
|
|
264
|
-
completed: new Set(),
|
|
265
|
-
isDownloading: false,
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const result = await ModelPipeline.getInstance(
|
|
270
|
-
modelId,
|
|
271
|
-
options,
|
|
272
|
-
(progress) => {
|
|
273
|
-
if (progress.status === "progress" && progress.file) {
|
|
274
|
-
const pct = Math.round(progress.progress || 0);
|
|
275
|
-
if (pct < 100) {
|
|
276
|
-
downloadState.downloading.add(progress.file);
|
|
277
|
-
downloadState.isDownloading = true;
|
|
278
|
-
} else if (pct === 100) {
|
|
279
|
-
downloadState.downloading.delete(progress.file);
|
|
280
|
-
downloadState.completed.add(progress.file);
|
|
281
|
-
}
|
|
282
|
-
if (downloadState.isDownloading) {
|
|
283
|
-
self.postMessage({
|
|
284
|
-
status: "downloading",
|
|
285
|
-
file: progress.file,
|
|
286
|
-
progress: pct,
|
|
287
|
-
downloadCount: downloadState.downloading.size,
|
|
288
|
-
totalFiles: downloadState.completed.size + downloadState.downloading.size,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
self.postMessage({ status: "loading", message: "Compiling shaders..." });
|
|
296
|
-
|
|
297
|
-
// Warmup differs for vision vs text models
|
|
298
|
-
if (result.isVision) {
|
|
299
|
-
// Vision models need both text and vision warmup
|
|
300
|
-
// Text warmup first
|
|
301
|
-
const textWarmupInputs = result.tokenizer("hello");
|
|
302
|
-
await result.model.generate({ ...textWarmupInputs, max_new_tokens: 1 });
|
|
303
|
-
|
|
304
|
-
// Vision warmup with synthetic image
|
|
305
|
-
self.postMessage({ status: "loading", message: "Warming up vision encoder..." });
|
|
306
|
-
try {
|
|
307
|
-
// Create a tiny 8x8 test image using OffscreenCanvas
|
|
308
|
-
const canvas = new OffscreenCanvas(8, 8);
|
|
309
|
-
const ctx = canvas.getContext("2d");
|
|
310
|
-
ctx.fillStyle = "red";
|
|
311
|
-
ctx.fillRect(0, 0, 8, 8);
|
|
312
|
-
const blob = await canvas.convertToBlob({ type: "image/png" });
|
|
313
|
-
const warmupImage = await RawImage.fromBlob(blob);
|
|
314
|
-
|
|
315
|
-
// Process with vision pipeline
|
|
316
|
-
const warmupContent = [{ type: "image" }, { type: "text", text: "hi" }];
|
|
317
|
-
const warmupMessages = [{ role: "user", content: warmupContent }];
|
|
318
|
-
const warmupPrompt = result.processor.apply_chat_template(warmupMessages, { add_generation_prompt: true });
|
|
319
|
-
const warmupInputs = await result.processor(warmupImage, warmupPrompt, { add_special_tokens: false });
|
|
320
|
-
|
|
321
|
-
// Run vision warmup generation
|
|
322
|
-
await result.model.generate({
|
|
323
|
-
...warmupInputs,
|
|
324
|
-
max_new_tokens: 1,
|
|
325
|
-
});
|
|
326
|
-
} catch (warmupErr) {
|
|
327
|
-
console.warn("Vision warmup failed (non-fatal):", warmupErr);
|
|
328
|
-
}
|
|
329
|
-
} else {
|
|
330
|
-
const warmupInputs = result.tokenizer("a");
|
|
331
|
-
await result.model.generate({ ...warmupInputs, max_new_tokens: 1 });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
self.postMessage({ status: "ready", isVision: result.isVision });
|
|
335
|
-
} catch (error) {
|
|
336
|
-
self.postMessage({ status: "error", error: error.message || String(error) });
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function generate(data) {
|
|
341
|
-
const { messages, images = [], options = {} } = data;
|
|
342
|
-
const { maxTokens = 256, temperature = 0.7, topP = 0.9, topK = 20, thinking = false } = options;
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
const result = await ModelPipeline.getInstance(ModelPipeline.modelId, {});
|
|
346
|
-
|
|
347
|
-
// Route to vision or text generation
|
|
348
|
-
if (result.isVision && images.length > 0) {
|
|
349
|
-
await generateVision(result, messages, images, options);
|
|
350
|
-
} else {
|
|
351
|
-
await generateText(result, messages, options);
|
|
352
|
-
}
|
|
353
|
-
} catch (error) {
|
|
354
|
-
self.postMessage({ status: "error", error: error.message || String(error) });
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function generateText(result, messages, options) {
|
|
359
|
-
const { maxTokens = 256, temperature = 0.7, topP = 0.9, topK = 20, thinking = false } = options;
|
|
360
|
-
const { tokenizer, model } = result;
|
|
361
|
-
|
|
362
|
-
const inputs = tokenizer.apply_chat_template(messages, {
|
|
363
|
-
add_generation_prompt: true,
|
|
364
|
-
return_dict: true,
|
|
365
|
-
enable_thinking: thinking,
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
let state = "answering";
|
|
369
|
-
const [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] = tokenizer.encode(
|
|
370
|
-
"<think></think>",
|
|
371
|
-
{ add_special_tokens: false }
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
let startTime = null;
|
|
375
|
-
let numTokens = 0;
|
|
376
|
-
|
|
377
|
-
const tokenCallback = (tokens) => {
|
|
378
|
-
startTime ??= performance.now();
|
|
379
|
-
numTokens += 1;
|
|
380
|
-
const tokenId = Number(tokens[0]);
|
|
381
|
-
if (tokenId === START_THINKING_TOKEN_ID) state = "thinking";
|
|
382
|
-
else if (tokenId === END_THINKING_TOKEN_ID) state = "answering";
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
const streamCallback = (text) => {
|
|
386
|
-
const tps = startTime ? (numTokens / (performance.now() - startTime)) * 1000 : 0;
|
|
387
|
-
self.postMessage({ status: "token", text, state, numTokens, tps });
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
const streamer = new TextStreamer(tokenizer, {
|
|
391
|
-
skip_prompt: true,
|
|
392
|
-
skip_special_tokens: true,
|
|
393
|
-
callback_function: streamCallback,
|
|
394
|
-
token_callback_function: tokenCallback,
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
self.postMessage({ status: "start" });
|
|
398
|
-
|
|
399
|
-
const { past_key_values, sequences } = await model.generate({
|
|
400
|
-
...inputs,
|
|
401
|
-
past_key_values: pastKeyValuesCache,
|
|
402
|
-
do_sample: temperature > 0,
|
|
403
|
-
temperature: temperature > 0 ? temperature : undefined,
|
|
404
|
-
top_p: topP,
|
|
405
|
-
top_k: topK,
|
|
406
|
-
max_new_tokens: maxTokens,
|
|
407
|
-
streamer,
|
|
408
|
-
stopping_criteria: stoppingCriteria,
|
|
409
|
-
return_dict_in_generate: true,
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
pastKeyValuesCache = past_key_values;
|
|
413
|
-
|
|
414
|
-
const endTime = performance.now();
|
|
415
|
-
const totalTime = startTime ? endTime - startTime : 0;
|
|
416
|
-
const decoded = tokenizer.batch_decode(sequences, { skip_special_tokens: true });
|
|
417
|
-
|
|
418
|
-
self.postMessage({
|
|
419
|
-
status: "complete",
|
|
420
|
-
text: decoded[0] || "",
|
|
421
|
-
numTokens,
|
|
422
|
-
totalTime,
|
|
423
|
-
tps: totalTime > 0 ? (numTokens / totalTime) * 1000 : 0,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function generateVision(result, messages, images, options) {
|
|
428
|
-
const { maxTokens = 2048, temperature = 0.7, topP = 0.9, topK = 20 } = options;
|
|
429
|
-
const { processor, model, tokenizer } = result;
|
|
430
|
-
|
|
431
|
-
self.postMessage({ status: "progress", message: "Preparing vision request..." });
|
|
432
|
-
|
|
433
|
-
// Build message content with image placeholders and text
|
|
434
|
-
const lastMessage = messages[messages.length - 1];
|
|
435
|
-
const content = [];
|
|
436
|
-
for (const _ of images) {
|
|
437
|
-
content.push({ type: "image" });
|
|
438
|
-
}
|
|
439
|
-
content.push({ type: "text", text: lastMessage.content });
|
|
440
|
-
|
|
441
|
-
// For vision models, include a brief system instruction for concise responses
|
|
442
|
-
// Note: Vision processors handle system differently than text models
|
|
443
|
-
const visionMessages = [
|
|
444
|
-
{ role: "system", content: "You are a helpful assistant. Be concise and direct in your responses." },
|
|
445
|
-
{ role: "user", content }
|
|
446
|
-
];
|
|
447
|
-
|
|
448
|
-
// Apply chat template with generation prompt
|
|
449
|
-
const chatPrompt = processor.apply_chat_template(visionMessages, {
|
|
450
|
-
add_generation_prompt: true
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// Load images (handle both string URLs and { source: string } objects)
|
|
454
|
-
self.postMessage({ status: "progress", message: "Loading images..." });
|
|
455
|
-
const loadedImages = await Promise.all(
|
|
456
|
-
images.map(img => {
|
|
457
|
-
const url = typeof img === "string" ? img : img.source;
|
|
458
|
-
return RawImage.fromURL(url);
|
|
459
|
-
})
|
|
460
|
-
);
|
|
461
|
-
self.postMessage({ status: "progress", message: "Processing inputs..." });
|
|
462
|
-
|
|
463
|
-
// Process inputs
|
|
464
|
-
const inputs = await processor(
|
|
465
|
-
loadedImages.length === 1 ? loadedImages[0] : loadedImages,
|
|
466
|
-
chatPrompt,
|
|
467
|
-
{ add_special_tokens: false }
|
|
468
|
-
);
|
|
469
|
-
self.postMessage({ status: "progress", message: "Generating response..." });
|
|
470
|
-
|
|
471
|
-
let startTime = null;
|
|
472
|
-
let numTokens = 0;
|
|
473
|
-
|
|
474
|
-
const streamCallback = (text) => {
|
|
475
|
-
startTime ??= performance.now();
|
|
476
|
-
numTokens += 1;
|
|
477
|
-
const tps = (numTokens / (performance.now() - startTime)) * 1000;
|
|
478
|
-
self.postMessage({ status: "token", text, state: "answering", numTokens, tps });
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
const streamer = new TextStreamer(tokenizer, {
|
|
482
|
-
skip_prompt: true,
|
|
483
|
-
skip_special_tokens: true,
|
|
484
|
-
callback_function: streamCallback,
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
self.postMessage({ status: "start" });
|
|
488
|
-
|
|
489
|
-
const outputs = await model.generate({
|
|
490
|
-
...inputs,
|
|
491
|
-
max_new_tokens: maxTokens,
|
|
492
|
-
do_sample: temperature > 0,
|
|
493
|
-
temperature: temperature > 0 ? temperature : undefined,
|
|
494
|
-
top_p: topP,
|
|
495
|
-
top_k: topK,
|
|
496
|
-
streamer,
|
|
497
|
-
stopping_criteria: stoppingCriteria,
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
// Decode output (skip prompt)
|
|
501
|
-
const inputLength = inputs.input_ids.dims?.at(-1) || 0;
|
|
502
|
-
const decoded = processor.batch_decode(
|
|
503
|
-
outputs.slice(null, [inputLength, null]),
|
|
504
|
-
{ skip_special_tokens: true }
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
const endTime = performance.now();
|
|
508
|
-
const totalTime = startTime ? endTime - startTime : 0;
|
|
509
|
-
|
|
510
|
-
self.postMessage({
|
|
511
|
-
status: "complete",
|
|
512
|
-
text: decoded[0] || "",
|
|
513
|
-
numTokens,
|
|
514
|
-
totalTime,
|
|
515
|
-
tps: totalTime > 0 ? (numTokens / totalTime) * 1000 : 0,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
self.addEventListener("message", async (e) => {
|
|
520
|
-
const { type, ...data } = e.data;
|
|
521
|
-
switch (type) {
|
|
522
|
-
case "load": await load(data); break;
|
|
523
|
-
case "generate": stoppingCriteria.reset(); await generate(data); break;
|
|
524
|
-
case "interrupt": stoppingCriteria.interrupt(); break;
|
|
525
|
-
case "reset": pastKeyValuesCache = null; stoppingCriteria.reset(); break;
|
|
526
|
-
}
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
self.postMessage({ status: "init" });
|
|
530
|
-
`], { type: "application/javascript" });
|
|
531
|
-
const workerUrl = URL.createObjectURL(blob);
|
|
532
|
-
const worker = new Worker(workerUrl, { type: "module" });
|
|
533
|
-
let isReady = false;
|
|
534
|
-
let currentResolve = null;
|
|
535
|
-
let currentReject = null;
|
|
536
|
-
let _generatedText = "";
|
|
537
|
-
worker.onmessage = (e) => {
|
|
538
|
-
const msg = e.data;
|
|
539
|
-
switch (msg.status) {
|
|
540
|
-
case "init":
|
|
541
|
-
worker.postMessage({
|
|
542
|
-
type: "load",
|
|
543
|
-
modelId: source.path
|
|
544
|
-
});
|
|
545
|
-
break;
|
|
546
|
-
case "loading":
|
|
547
|
-
case "downloading":
|
|
548
|
-
onProgress?.(msg);
|
|
549
|
-
break;
|
|
550
|
-
case "ready":
|
|
551
|
-
isReady = true;
|
|
552
|
-
onProgress?.(msg);
|
|
553
|
-
resolve(gerbilWorker);
|
|
554
|
-
break;
|
|
555
|
-
case "start":
|
|
556
|
-
_generatedText = "";
|
|
557
|
-
break;
|
|
558
|
-
case "token":
|
|
559
|
-
_generatedText += msg.text;
|
|
560
|
-
onToken?.(msg);
|
|
561
|
-
break;
|
|
562
|
-
case "complete":
|
|
563
|
-
onComplete?.(msg);
|
|
564
|
-
currentResolve?.(msg.text);
|
|
565
|
-
currentResolve = null;
|
|
566
|
-
currentReject = null;
|
|
567
|
-
break;
|
|
568
|
-
case "error":
|
|
569
|
-
onError?.(msg.error);
|
|
570
|
-
onProgress?.({
|
|
571
|
-
status: "error",
|
|
572
|
-
error: msg.error
|
|
573
|
-
});
|
|
574
|
-
if (currentReject) {
|
|
575
|
-
currentReject(new Error(msg.error));
|
|
576
|
-
currentResolve = null;
|
|
577
|
-
currentReject = null;
|
|
578
|
-
} else reject(new Error(msg.error));
|
|
579
|
-
break;
|
|
580
|
-
}
|
|
581
|
-
};
|
|
582
|
-
worker.onerror = (e) => {
|
|
583
|
-
const error = e.message || "Worker error";
|
|
584
|
-
onError?.(error);
|
|
585
|
-
reject(new Error(error));
|
|
586
|
-
};
|
|
587
|
-
const gerbilWorker = {
|
|
588
|
-
generate: (prompt, options$1 = {}) => new Promise((res, rej) => {
|
|
589
|
-
currentResolve = res;
|
|
590
|
-
currentReject = rej;
|
|
591
|
-
const system = options$1.system || "You are a helpful assistant.";
|
|
592
|
-
const messages = options$1.history ? [{
|
|
593
|
-
role: "system",
|
|
594
|
-
content: system
|
|
595
|
-
}, ...options$1.history] : [{
|
|
596
|
-
role: "system",
|
|
597
|
-
content: system
|
|
598
|
-
}, {
|
|
599
|
-
role: "user",
|
|
600
|
-
content: prompt
|
|
601
|
-
}];
|
|
602
|
-
if (options$1.history) worker.postMessage({ type: "reset" });
|
|
603
|
-
worker.postMessage({
|
|
604
|
-
type: "generate",
|
|
605
|
-
messages,
|
|
606
|
-
images: options$1.images || [],
|
|
607
|
-
options: {
|
|
608
|
-
maxTokens: options$1.maxTokens ?? (options$1.images?.length ? 2048 : 256),
|
|
609
|
-
temperature: options$1.temperature ?? .7,
|
|
610
|
-
topP: options$1.topP ?? .9,
|
|
611
|
-
topK: options$1.topK ?? 20,
|
|
612
|
-
thinking: options$1.thinking ?? false
|
|
613
|
-
}
|
|
614
|
-
});
|
|
615
|
-
}),
|
|
616
|
-
interrupt: () => {
|
|
617
|
-
worker.postMessage({ type: "interrupt" });
|
|
618
|
-
},
|
|
619
|
-
reset: () => {
|
|
620
|
-
worker.postMessage({ type: "reset" });
|
|
621
|
-
},
|
|
622
|
-
terminate: () => {
|
|
623
|
-
worker.terminate();
|
|
624
|
-
URL.revokeObjectURL(workerUrl);
|
|
625
|
-
},
|
|
626
|
-
isReady: () => isReady
|
|
627
|
-
};
|
|
628
|
-
});
|
|
60
|
+
/** True when the page is running as an installed/standalone PWA (Home Screen). */
|
|
61
|
+
function isStandalone() {
|
|
62
|
+
if (typeof window === "undefined") return false;
|
|
63
|
+
const iosStandalone = navigator.standalone === true;
|
|
64
|
+
const displayStandalone = typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches;
|
|
65
|
+
return iosStandalone || displayStandalone;
|
|
629
66
|
}
|
|
630
|
-
/**
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
function useChat(options = {}) {
|
|
657
|
-
const React = globalThis.React;
|
|
658
|
-
if (!React) throw new Error("useChat requires React. Import React before using this hook.");
|
|
659
|
-
const { useState, useEffect, useRef, useCallback } = React;
|
|
660
|
-
const { model = "qwen3-0.6b", system = "You are a helpful assistant.", thinking: enableThinking = false, maxTokens = 512, temperature = .7, initialMessages = [], autoLoad = false, onReady, onError } = options;
|
|
661
|
-
const [messages, setMessages] = useState(initialMessages);
|
|
662
|
-
const [input, setInput] = useState("");
|
|
663
|
-
const [isLoading, setIsLoading] = useState(autoLoad);
|
|
664
|
-
const [loadingProgress, setLoadingProgress] = useState(null);
|
|
665
|
-
const [isGenerating, setIsGenerating] = useState(false);
|
|
666
|
-
const [thinking, setThinking] = useState("");
|
|
667
|
-
const [currentResponse, setCurrentResponse] = useState("");
|
|
668
|
-
const [tps, setTps] = useState(0);
|
|
669
|
-
const [error, setError] = useState(null);
|
|
670
|
-
const [isReady, setIsReady] = useState(false);
|
|
671
|
-
const [shouldLoad, setShouldLoad] = useState(autoLoad);
|
|
672
|
-
const [attachedImages, setAttachedImages] = useState([]);
|
|
673
|
-
const workerRef = useRef(null);
|
|
674
|
-
const messageIdRef = useRef(0);
|
|
675
|
-
const mountedRef = useRef(true);
|
|
676
|
-
const load = useCallback(() => {
|
|
677
|
-
if (workerRef.current || isLoading) return;
|
|
678
|
-
setIsLoading(true);
|
|
679
|
-
setShouldLoad(true);
|
|
680
|
-
}, [isLoading]);
|
|
681
|
-
useEffect(() => {
|
|
682
|
-
if (!shouldLoad) return;
|
|
683
|
-
if (!isWebGPUSupported()) {
|
|
684
|
-
setError("WebGPU not supported. Use Chrome/Edge 113+.");
|
|
685
|
-
setIsLoading(false);
|
|
686
|
-
onError?.("WebGPU not supported");
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
mountedRef.current = true;
|
|
690
|
-
createGerbilWorker({
|
|
691
|
-
modelId: model,
|
|
692
|
-
onProgress: (p) => {
|
|
693
|
-
if (!mountedRef.current) return;
|
|
694
|
-
setLoadingProgress(p);
|
|
695
|
-
if (p.status === "ready") {
|
|
696
|
-
setIsLoading(false);
|
|
697
|
-
setIsReady(true);
|
|
698
|
-
onReady?.();
|
|
699
|
-
}
|
|
700
|
-
},
|
|
701
|
-
onToken: (token) => {
|
|
702
|
-
if (!mountedRef.current) return;
|
|
703
|
-
setTps(token.tps);
|
|
704
|
-
if (token.state === "thinking") setThinking((t) => t + token.text);
|
|
705
|
-
else setCurrentResponse((r) => r + token.text);
|
|
706
|
-
},
|
|
707
|
-
onComplete: () => {
|
|
708
|
-
if (!mountedRef.current) return;
|
|
709
|
-
setIsGenerating(false);
|
|
710
|
-
},
|
|
711
|
-
onError: (err) => {
|
|
712
|
-
if (!mountedRef.current) return;
|
|
713
|
-
setError(err);
|
|
714
|
-
setIsGenerating(false);
|
|
715
|
-
onError?.(err);
|
|
716
|
-
}
|
|
717
|
-
}).then((worker) => {
|
|
718
|
-
if (mountedRef.current) workerRef.current = worker;
|
|
719
|
-
else worker.terminate();
|
|
720
|
-
}).catch((err) => {
|
|
721
|
-
if (mountedRef.current) {
|
|
722
|
-
setError(err.message);
|
|
723
|
-
setIsLoading(false);
|
|
724
|
-
onError?.(err.message);
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
return () => {
|
|
728
|
-
mountedRef.current = false;
|
|
729
|
-
workerRef.current?.terminate();
|
|
730
|
-
};
|
|
731
|
-
}, [model, shouldLoad]);
|
|
732
|
-
useEffect(() => {
|
|
733
|
-
if (!isGenerating && currentResponse) {
|
|
734
|
-
setMessages((msgs) => {
|
|
735
|
-
if (msgs.at(-1)?.role === "assistant") return msgs.map((m, i) => i === msgs.length - 1 ? {
|
|
736
|
-
...m,
|
|
737
|
-
content: currentResponse,
|
|
738
|
-
thinking: thinking || void 0
|
|
739
|
-
} : m);
|
|
740
|
-
return msgs;
|
|
741
|
-
});
|
|
742
|
-
setCurrentResponse("");
|
|
743
|
-
setThinking("");
|
|
744
|
-
}
|
|
745
|
-
}, [
|
|
746
|
-
isGenerating,
|
|
747
|
-
currentResponse,
|
|
748
|
-
thinking
|
|
749
|
-
]);
|
|
750
|
-
const pendingMessageRef = useRef(null);
|
|
751
|
-
const pendingImagesRef = useRef([]);
|
|
752
|
-
const attachImage = useCallback((imageUrl) => {
|
|
753
|
-
setAttachedImages((imgs) => [...imgs, imageUrl]);
|
|
754
|
-
}, []);
|
|
755
|
-
const removeImage = useCallback((index) => {
|
|
756
|
-
setAttachedImages((imgs) => imgs.filter((_, i) => i !== index));
|
|
757
|
-
}, []);
|
|
758
|
-
const clearImages = useCallback(() => {
|
|
759
|
-
setAttachedImages([]);
|
|
760
|
-
}, []);
|
|
761
|
-
const sendMessageWithImages = useCallback((text, images) => {
|
|
762
|
-
if (!text.trim() || isGenerating) return;
|
|
763
|
-
messageIdRef.current += 1;
|
|
764
|
-
const userMessage = {
|
|
765
|
-
id: `msg-${messageIdRef.current}`,
|
|
766
|
-
role: "user",
|
|
767
|
-
content: text.trim(),
|
|
768
|
-
images: images.length > 0 ? images : void 0
|
|
769
|
-
};
|
|
770
|
-
messageIdRef.current += 1;
|
|
771
|
-
const assistantMessage = {
|
|
772
|
-
id: `msg-${messageIdRef.current}`,
|
|
773
|
-
role: "assistant",
|
|
774
|
-
content: ""
|
|
775
|
-
};
|
|
776
|
-
setMessages((msgs) => [
|
|
777
|
-
...msgs,
|
|
778
|
-
userMessage,
|
|
779
|
-
assistantMessage
|
|
780
|
-
]);
|
|
781
|
-
setCurrentResponse("");
|
|
782
|
-
setThinking("");
|
|
783
|
-
if (!workerRef.current) {
|
|
784
|
-
pendingMessageRef.current = text.trim();
|
|
785
|
-
pendingImagesRef.current = images;
|
|
786
|
-
load();
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
setIsGenerating(true);
|
|
790
|
-
workerRef.current.generate(text.trim(), {
|
|
791
|
-
system,
|
|
792
|
-
thinking: enableThinking,
|
|
793
|
-
maxTokens: images.length > 0 ? Math.max(maxTokens, 2048) : maxTokens,
|
|
794
|
-
temperature,
|
|
795
|
-
images: images.length > 0 ? images : void 0
|
|
796
|
-
});
|
|
797
|
-
}, [
|
|
798
|
-
isGenerating,
|
|
799
|
-
system,
|
|
800
|
-
enableThinking,
|
|
801
|
-
maxTokens,
|
|
802
|
-
temperature,
|
|
803
|
-
load
|
|
804
|
-
]);
|
|
805
|
-
const handleSubmit = useCallback((e) => {
|
|
806
|
-
e?.preventDefault?.();
|
|
807
|
-
if (!input.trim() || isGenerating) return;
|
|
808
|
-
sendMessageWithImages(input, attachedImages);
|
|
809
|
-
setInput("");
|
|
810
|
-
setAttachedImages([]);
|
|
811
|
-
}, [
|
|
812
|
-
input,
|
|
813
|
-
isGenerating,
|
|
814
|
-
attachedImages,
|
|
815
|
-
sendMessageWithImages
|
|
816
|
-
]);
|
|
817
|
-
const sendWithImages = useCallback((text, images) => {
|
|
818
|
-
sendMessageWithImages(text, images);
|
|
819
|
-
}, [sendMessageWithImages]);
|
|
820
|
-
useEffect(() => {
|
|
821
|
-
if (isReady && pendingMessageRef.current && workerRef.current) {
|
|
822
|
-
const pendingContent = pendingMessageRef.current;
|
|
823
|
-
const pendingImages = pendingImagesRef.current;
|
|
824
|
-
pendingMessageRef.current = null;
|
|
825
|
-
pendingImagesRef.current = [];
|
|
826
|
-
setIsGenerating(true);
|
|
827
|
-
workerRef.current.generate(pendingContent, {
|
|
828
|
-
system,
|
|
829
|
-
thinking: enableThinking,
|
|
830
|
-
maxTokens: pendingImages.length > 0 ? Math.max(maxTokens, 2048) : maxTokens,
|
|
831
|
-
temperature,
|
|
832
|
-
images: pendingImages.length > 0 ? pendingImages : void 0
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
}, [
|
|
836
|
-
isReady,
|
|
837
|
-
system,
|
|
838
|
-
enableThinking,
|
|
839
|
-
maxTokens,
|
|
840
|
-
temperature
|
|
841
|
-
]);
|
|
842
|
-
const stop = useCallback(() => {
|
|
843
|
-
workerRef.current?.interrupt();
|
|
844
|
-
setIsGenerating(false);
|
|
845
|
-
}, []);
|
|
846
|
-
const clear = useCallback(() => {
|
|
847
|
-
workerRef.current?.reset();
|
|
848
|
-
setMessages([]);
|
|
849
|
-
setCurrentResponse("");
|
|
850
|
-
setThinking("");
|
|
851
|
-
setAttachedImages([]);
|
|
852
|
-
}, []);
|
|
67
|
+
/** True when running on iOS/iPadOS (where install is the quota unlock and the
|
|
68
|
+
* install flow is manual: Share → Add to Home Screen). iPadOS masquerades as
|
|
69
|
+
* macOS, so we also treat touch-capable WebKit-on-Mac as iOS. */
|
|
70
|
+
function isIOS() {
|
|
71
|
+
if (typeof navigator === "undefined") return false;
|
|
72
|
+
const ua = navigator.userAgent || "";
|
|
73
|
+
if (/iPhone|iPad|iPod/.test(ua)) return true;
|
|
74
|
+
return /Macintosh/.test(ua) && /AppleWebKit/.test(ua) && !/Chrome/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1;
|
|
75
|
+
}
|
|
76
|
+
/** Snapshot of the origin's storage situation — quota, usage, persistence, and
|
|
77
|
+
* whether the app is installed. Use it to decide whether to recommend install
|
|
78
|
+
* before downloading a large model. */
|
|
79
|
+
async function getStorageStatus() {
|
|
80
|
+
const installed = isStandalone();
|
|
81
|
+
const ios = isIOS();
|
|
82
|
+
let quotaMB = 0;
|
|
83
|
+
let usageMB = 0;
|
|
84
|
+
let persisted = false;
|
|
85
|
+
try {
|
|
86
|
+
const est = await navigator.storage?.estimate?.();
|
|
87
|
+
quotaMB = Math.round((est?.quota || 0) / 1e6);
|
|
88
|
+
usageMB = Math.round((est?.usage || 0) / 1e6);
|
|
89
|
+
} catch {}
|
|
90
|
+
try {
|
|
91
|
+
persisted = await navigator.storage?.persisted?.() ?? false;
|
|
92
|
+
} catch {}
|
|
853
93
|
return {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
return m;
|
|
861
|
-
}),
|
|
862
|
-
input,
|
|
863
|
-
setInput,
|
|
864
|
-
handleSubmit,
|
|
865
|
-
isLoading,
|
|
866
|
-
loadingProgress,
|
|
867
|
-
isGenerating,
|
|
868
|
-
thinking,
|
|
869
|
-
stop,
|
|
870
|
-
clear,
|
|
871
|
-
tps,
|
|
872
|
-
isReady,
|
|
873
|
-
error,
|
|
874
|
-
load,
|
|
875
|
-
attachedImages,
|
|
876
|
-
attachImage,
|
|
877
|
-
removeImage,
|
|
878
|
-
clearImages,
|
|
879
|
-
sendWithImages
|
|
94
|
+
quotaMB,
|
|
95
|
+
usageMB,
|
|
96
|
+
availableMB: Math.max(0, quotaMB - usageMB),
|
|
97
|
+
persisted,
|
|
98
|
+
installed,
|
|
99
|
+
ios
|
|
880
100
|
};
|
|
881
101
|
}
|
|
882
102
|
/**
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* import { useCompletion } from "@tryhamster/gerbil/browser";
|
|
888
|
-
*
|
|
889
|
-
* function App() {
|
|
890
|
-
* const { complete, completion, isLoading, isGenerating } = useCompletion();
|
|
891
|
-
*
|
|
892
|
-
* if (isLoading) return <div>Loading...</div>;
|
|
893
|
-
*
|
|
894
|
-
* return (
|
|
895
|
-
* <div>
|
|
896
|
-
* <button onClick={() => complete("Write a haiku")}>Generate</button>
|
|
897
|
-
* <p>{completion}</p>
|
|
898
|
-
* </div>
|
|
899
|
-
* );
|
|
900
|
-
* }
|
|
901
|
-
* ```
|
|
103
|
+
* Request persistent storage (exempt from eviction). Returns whether the origin
|
|
104
|
+
* is persistent afterwards. Browsers grant this based on engagement/installation;
|
|
105
|
+
* on iOS Safari it is effectively granted only to an installed (Home Screen) PWA,
|
|
106
|
+
* so call this AND guide users to install when it returns false on iOS.
|
|
902
107
|
*/
|
|
903
|
-
function
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const
|
|
918
|
-
const
|
|
919
|
-
const
|
|
920
|
-
const pendingPromptRef = useRef(null);
|
|
921
|
-
const pendingImagesRef = useRef(void 0);
|
|
922
|
-
const mountedRef = useRef(true);
|
|
923
|
-
const load = useCallback(() => {
|
|
924
|
-
if (workerRef.current || isLoading) return;
|
|
925
|
-
setIsLoading(true);
|
|
926
|
-
setShouldLoad(true);
|
|
927
|
-
}, [isLoading]);
|
|
928
|
-
useEffect(() => {
|
|
929
|
-
if (!shouldLoad) return;
|
|
930
|
-
if (!isWebGPUSupported()) {
|
|
931
|
-
setError("WebGPU not supported. Use Chrome/Edge 113+.");
|
|
932
|
-
setIsLoading(false);
|
|
933
|
-
onError?.("WebGPU not supported");
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
mountedRef.current = true;
|
|
937
|
-
createGerbilWorker({
|
|
938
|
-
modelId: model,
|
|
939
|
-
onProgress: (p) => {
|
|
940
|
-
if (!mountedRef.current) return;
|
|
941
|
-
setLoadingProgress(p);
|
|
942
|
-
if (p.status === "ready") {
|
|
943
|
-
setIsLoading(false);
|
|
944
|
-
setIsReady(true);
|
|
945
|
-
onReady?.();
|
|
946
|
-
}
|
|
947
|
-
},
|
|
948
|
-
onToken: (token) => {
|
|
949
|
-
if (!mountedRef.current) return;
|
|
950
|
-
setTps(token.tps);
|
|
951
|
-
if (token.state === "thinking") setThinking((t) => t + token.text);
|
|
952
|
-
else setCompletion((c) => c + token.text);
|
|
953
|
-
},
|
|
954
|
-
onComplete: (result) => {
|
|
955
|
-
if (!mountedRef.current) return;
|
|
956
|
-
setIsGenerating(false);
|
|
957
|
-
resolveRef.current?.(result.text);
|
|
958
|
-
resolveRef.current = null;
|
|
959
|
-
},
|
|
960
|
-
onError: (err) => {
|
|
961
|
-
if (!mountedRef.current) return;
|
|
962
|
-
setError(err);
|
|
963
|
-
setIsGenerating(false);
|
|
964
|
-
onError?.(err);
|
|
965
|
-
}
|
|
966
|
-
}).then((worker) => {
|
|
967
|
-
if (mountedRef.current) workerRef.current = worker;
|
|
968
|
-
else worker.terminate();
|
|
969
|
-
}).catch((err) => {
|
|
970
|
-
if (mountedRef.current) {
|
|
971
|
-
setError(err.message);
|
|
972
|
-
setIsLoading(false);
|
|
973
|
-
onError?.(err.message);
|
|
974
|
-
}
|
|
975
|
-
});
|
|
976
|
-
return () => {
|
|
977
|
-
mountedRef.current = false;
|
|
978
|
-
workerRef.current?.terminate();
|
|
979
|
-
};
|
|
980
|
-
}, [model, shouldLoad]);
|
|
981
|
-
const complete = useCallback((prompt, completeOptions) => {
|
|
982
|
-
return new Promise((resolve, reject) => {
|
|
983
|
-
setCompletion("");
|
|
984
|
-
setThinking("");
|
|
985
|
-
resolveRef.current = resolve;
|
|
986
|
-
rejectRef.current = reject;
|
|
987
|
-
if (!workerRef.current) {
|
|
988
|
-
pendingPromptRef.current = prompt;
|
|
989
|
-
pendingImagesRef.current = completeOptions?.images;
|
|
990
|
-
load();
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
setIsGenerating(true);
|
|
994
|
-
workerRef.current.generate(prompt, {
|
|
995
|
-
system,
|
|
996
|
-
thinking: enableThinking,
|
|
997
|
-
maxTokens,
|
|
998
|
-
temperature,
|
|
999
|
-
images: completeOptions?.images
|
|
1000
|
-
});
|
|
1001
|
-
});
|
|
1002
|
-
}, [
|
|
1003
|
-
system,
|
|
1004
|
-
enableThinking,
|
|
1005
|
-
maxTokens,
|
|
1006
|
-
temperature,
|
|
1007
|
-
load
|
|
1008
|
-
]);
|
|
1009
|
-
useEffect(() => {
|
|
1010
|
-
if (isReady && pendingPromptRef.current && workerRef.current) {
|
|
1011
|
-
const pendingPrompt = pendingPromptRef.current;
|
|
1012
|
-
const pendingImages = pendingImagesRef.current;
|
|
1013
|
-
pendingPromptRef.current = null;
|
|
1014
|
-
pendingImagesRef.current = void 0;
|
|
1015
|
-
setIsGenerating(true);
|
|
1016
|
-
workerRef.current.generate(pendingPrompt, {
|
|
1017
|
-
system,
|
|
1018
|
-
thinking: enableThinking,
|
|
1019
|
-
maxTokens,
|
|
1020
|
-
temperature,
|
|
1021
|
-
images: pendingImages
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
}, [
|
|
1025
|
-
isReady,
|
|
1026
|
-
system,
|
|
1027
|
-
enableThinking,
|
|
1028
|
-
maxTokens,
|
|
1029
|
-
temperature
|
|
1030
|
-
]);
|
|
108
|
+
async function requestPersistentStorage() {
|
|
109
|
+
try {
|
|
110
|
+
if (await navigator.storage?.persisted?.()) return true;
|
|
111
|
+
return await navigator.storage?.persist?.() ?? false;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Estimate whether a model of `sizeMB` will cache in the current quota, and
|
|
118
|
+
* whether you should recommend installing to the Home Screen first. Pair with a
|
|
119
|
+
* one-time "Install for offline use" prompt before a large download on mobile.
|
|
120
|
+
*/
|
|
121
|
+
async function canCacheModel(sizeMB) {
|
|
122
|
+
const s = await getStorageStatus();
|
|
123
|
+
const fits = s.availableMB >= sizeMB * 1.1;
|
|
124
|
+
const recommendInstall = (!fits || s.ios && !s.installed) && !s.persisted;
|
|
1031
125
|
return {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
isLoading,
|
|
1036
|
-
loadingProgress,
|
|
1037
|
-
isGenerating,
|
|
1038
|
-
stop: useCallback(() => {
|
|
1039
|
-
workerRef.current?.interrupt();
|
|
1040
|
-
setIsGenerating(false);
|
|
1041
|
-
}, []),
|
|
1042
|
-
tps,
|
|
1043
|
-
isReady,
|
|
1044
|
-
error,
|
|
1045
|
-
load
|
|
126
|
+
fits,
|
|
127
|
+
availableMB: s.availableMB,
|
|
128
|
+
recommendInstall
|
|
1046
129
|
};
|
|
1047
130
|
}
|
|
1048
|
-
/** Kokoro voice definitions (24kHz, high quality) */
|
|
1049
|
-
const KOKORO_BROWSER_VOICES = [
|
|
1050
|
-
{
|
|
1051
|
-
id: "af_heart",
|
|
1052
|
-
name: "Heart",
|
|
1053
|
-
gender: "female",
|
|
1054
|
-
language: "en-us",
|
|
1055
|
-
description: "American female, highest quality (Grade A)"
|
|
1056
|
-
},
|
|
1057
|
-
{
|
|
1058
|
-
id: "af_bella",
|
|
1059
|
-
name: "Bella",
|
|
1060
|
-
gender: "female",
|
|
1061
|
-
language: "en-us",
|
|
1062
|
-
description: "American female, warm and friendly (Grade A-)"
|
|
1063
|
-
},
|
|
1064
|
-
{
|
|
1065
|
-
id: "af_nicole",
|
|
1066
|
-
name: "Nicole",
|
|
1067
|
-
gender: "female",
|
|
1068
|
-
language: "en-us",
|
|
1069
|
-
description: "American female, soft and gentle"
|
|
1070
|
-
},
|
|
1071
|
-
{
|
|
1072
|
-
id: "af_sarah",
|
|
1073
|
-
name: "Sarah",
|
|
1074
|
-
gender: "female",
|
|
1075
|
-
language: "en-us",
|
|
1076
|
-
description: "American female, clear and professional"
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
id: "af_sky",
|
|
1080
|
-
name: "Sky",
|
|
1081
|
-
gender: "female",
|
|
1082
|
-
language: "en-us",
|
|
1083
|
-
description: "American female, young and energetic"
|
|
1084
|
-
},
|
|
1085
|
-
{
|
|
1086
|
-
id: "af_alloy",
|
|
1087
|
-
name: "Alloy",
|
|
1088
|
-
gender: "female",
|
|
1089
|
-
language: "en-us",
|
|
1090
|
-
description: "American female"
|
|
1091
|
-
},
|
|
1092
|
-
{
|
|
1093
|
-
id: "af_aoede",
|
|
1094
|
-
name: "Aoede",
|
|
1095
|
-
gender: "female",
|
|
1096
|
-
language: "en-us",
|
|
1097
|
-
description: "American female, mythical"
|
|
1098
|
-
},
|
|
1099
|
-
{
|
|
1100
|
-
id: "af_jessica",
|
|
1101
|
-
name: "Jessica",
|
|
1102
|
-
gender: "female",
|
|
1103
|
-
language: "en-us",
|
|
1104
|
-
description: "American female"
|
|
1105
|
-
},
|
|
1106
|
-
{
|
|
1107
|
-
id: "af_kore",
|
|
1108
|
-
name: "Kore",
|
|
1109
|
-
gender: "female",
|
|
1110
|
-
language: "en-us",
|
|
1111
|
-
description: "American female"
|
|
1112
|
-
},
|
|
1113
|
-
{
|
|
1114
|
-
id: "af_nova",
|
|
1115
|
-
name: "Nova",
|
|
1116
|
-
gender: "female",
|
|
1117
|
-
language: "en-us",
|
|
1118
|
-
description: "American female"
|
|
1119
|
-
},
|
|
1120
|
-
{
|
|
1121
|
-
id: "af_river",
|
|
1122
|
-
name: "River",
|
|
1123
|
-
gender: "female",
|
|
1124
|
-
language: "en-us",
|
|
1125
|
-
description: "American female"
|
|
1126
|
-
},
|
|
1127
|
-
{
|
|
1128
|
-
id: "am_fenrir",
|
|
1129
|
-
name: "Fenrir",
|
|
1130
|
-
gender: "male",
|
|
1131
|
-
language: "en-us",
|
|
1132
|
-
description: "American male, best quality"
|
|
1133
|
-
},
|
|
1134
|
-
{
|
|
1135
|
-
id: "am_michael",
|
|
1136
|
-
name: "Michael",
|
|
1137
|
-
gender: "male",
|
|
1138
|
-
language: "en-us",
|
|
1139
|
-
description: "American male, warm and friendly"
|
|
1140
|
-
},
|
|
1141
|
-
{
|
|
1142
|
-
id: "am_adam",
|
|
1143
|
-
name: "Adam",
|
|
1144
|
-
gender: "male",
|
|
1145
|
-
language: "en-us",
|
|
1146
|
-
description: "American male"
|
|
1147
|
-
},
|
|
1148
|
-
{
|
|
1149
|
-
id: "am_echo",
|
|
1150
|
-
name: "Echo",
|
|
1151
|
-
gender: "male",
|
|
1152
|
-
language: "en-us",
|
|
1153
|
-
description: "American male"
|
|
1154
|
-
},
|
|
1155
|
-
{
|
|
1156
|
-
id: "am_eric",
|
|
1157
|
-
name: "Eric",
|
|
1158
|
-
gender: "male",
|
|
1159
|
-
language: "en-us",
|
|
1160
|
-
description: "American male"
|
|
1161
|
-
},
|
|
1162
|
-
{
|
|
1163
|
-
id: "am_liam",
|
|
1164
|
-
name: "Liam",
|
|
1165
|
-
gender: "male",
|
|
1166
|
-
language: "en-us",
|
|
1167
|
-
description: "American male"
|
|
1168
|
-
},
|
|
1169
|
-
{
|
|
1170
|
-
id: "am_onyx",
|
|
1171
|
-
name: "Onyx",
|
|
1172
|
-
gender: "male",
|
|
1173
|
-
language: "en-us",
|
|
1174
|
-
description: "American male"
|
|
1175
|
-
},
|
|
1176
|
-
{
|
|
1177
|
-
id: "am_puck",
|
|
1178
|
-
name: "Puck",
|
|
1179
|
-
gender: "male",
|
|
1180
|
-
language: "en-us",
|
|
1181
|
-
description: "American male"
|
|
1182
|
-
},
|
|
1183
|
-
{
|
|
1184
|
-
id: "am_santa",
|
|
1185
|
-
name: "Santa",
|
|
1186
|
-
gender: "male",
|
|
1187
|
-
language: "en-us",
|
|
1188
|
-
description: "American male, festive"
|
|
1189
|
-
},
|
|
1190
|
-
{
|
|
1191
|
-
id: "bf_emma",
|
|
1192
|
-
name: "Emma",
|
|
1193
|
-
gender: "female",
|
|
1194
|
-
language: "en-gb",
|
|
1195
|
-
description: "British female, elegant and clear"
|
|
1196
|
-
},
|
|
1197
|
-
{
|
|
1198
|
-
id: "bf_isabella",
|
|
1199
|
-
name: "Isabella",
|
|
1200
|
-
gender: "female",
|
|
1201
|
-
language: "en-gb",
|
|
1202
|
-
description: "British female, sophisticated"
|
|
1203
|
-
},
|
|
1204
|
-
{
|
|
1205
|
-
id: "bf_alice",
|
|
1206
|
-
name: "Alice",
|
|
1207
|
-
gender: "female",
|
|
1208
|
-
language: "en-gb",
|
|
1209
|
-
description: "British female"
|
|
1210
|
-
},
|
|
1211
|
-
{
|
|
1212
|
-
id: "bf_lily",
|
|
1213
|
-
name: "Lily",
|
|
1214
|
-
gender: "female",
|
|
1215
|
-
language: "en-gb",
|
|
1216
|
-
description: "British female"
|
|
1217
|
-
},
|
|
1218
|
-
{
|
|
1219
|
-
id: "bm_george",
|
|
1220
|
-
name: "George",
|
|
1221
|
-
gender: "male",
|
|
1222
|
-
language: "en-gb",
|
|
1223
|
-
description: "British male, distinguished"
|
|
1224
|
-
},
|
|
1225
|
-
{
|
|
1226
|
-
id: "bm_lewis",
|
|
1227
|
-
name: "Lewis",
|
|
1228
|
-
gender: "male",
|
|
1229
|
-
language: "en-gb",
|
|
1230
|
-
description: "British male, friendly"
|
|
1231
|
-
},
|
|
1232
|
-
{
|
|
1233
|
-
id: "bm_daniel",
|
|
1234
|
-
name: "Daniel",
|
|
1235
|
-
gender: "male",
|
|
1236
|
-
language: "en-gb",
|
|
1237
|
-
description: "British male"
|
|
1238
|
-
},
|
|
1239
|
-
{
|
|
1240
|
-
id: "bm_fable",
|
|
1241
|
-
name: "Fable",
|
|
1242
|
-
gender: "male",
|
|
1243
|
-
language: "en-gb",
|
|
1244
|
-
description: "British male"
|
|
1245
|
-
}
|
|
1246
|
-
];
|
|
1247
|
-
/** Supertonic voice definitions (44.1kHz, faster) */
|
|
1248
|
-
const SUPERTONIC_BROWSER_VOICES = [
|
|
1249
|
-
{
|
|
1250
|
-
id: "F1",
|
|
1251
|
-
name: "Female 1",
|
|
1252
|
-
gender: "female",
|
|
1253
|
-
language: "en",
|
|
1254
|
-
description: "Female voice 1 - Clear and natural"
|
|
1255
|
-
},
|
|
1256
|
-
{
|
|
1257
|
-
id: "F2",
|
|
1258
|
-
name: "Female 2",
|
|
1259
|
-
gender: "female",
|
|
1260
|
-
language: "en",
|
|
1261
|
-
description: "Female voice 2 - Warm and expressive"
|
|
1262
|
-
},
|
|
1263
|
-
{
|
|
1264
|
-
id: "M1",
|
|
1265
|
-
name: "Male 1",
|
|
1266
|
-
gender: "male",
|
|
1267
|
-
language: "en",
|
|
1268
|
-
description: "Male voice 1 - Deep and confident"
|
|
1269
|
-
},
|
|
1270
|
-
{
|
|
1271
|
-
id: "M2",
|
|
1272
|
-
name: "Male 2",
|
|
1273
|
-
gender: "male",
|
|
1274
|
-
language: "en",
|
|
1275
|
-
description: "Male voice 2 - Friendly and casual"
|
|
1276
|
-
}
|
|
1277
|
-
];
|
|
1278
|
-
/** TTS model configuration */
|
|
1279
|
-
const TTS_MODELS = {
|
|
1280
|
-
"kokoro-82m": {
|
|
1281
|
-
repo: "onnx-community/Kokoro-82M-v1.0-ONNX",
|
|
1282
|
-
defaultVoice: "af_heart",
|
|
1283
|
-
sampleRate: 24e3,
|
|
1284
|
-
voices: KOKORO_BROWSER_VOICES
|
|
1285
|
-
},
|
|
1286
|
-
"supertonic-66m": {
|
|
1287
|
-
repo: "onnx-community/Supertonic-TTS-ONNX",
|
|
1288
|
-
defaultVoice: "F1",
|
|
1289
|
-
sampleRate: 44100,
|
|
1290
|
-
voices: SUPERTONIC_BROWSER_VOICES
|
|
1291
|
-
}
|
|
1292
|
-
};
|
|
1293
131
|
/**
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
* @example
|
|
1299
|
-
* ```tsx
|
|
1300
|
-
* import { useSpeech } from "@tryhamster/gerbil/browser";
|
|
1301
|
-
*
|
|
1302
|
-
* function App() {
|
|
1303
|
-
* // Default: Kokoro TTS
|
|
1304
|
-
* const { speak, stop, isLoading, isSpeaking, listVoices, setVoice } = useSpeech();
|
|
1305
|
-
*
|
|
1306
|
-
* // Or use Supertonic (44.1kHz, faster)
|
|
1307
|
-
* // const { speak, listVoices } = useSpeech({ model: "supertonic-66m" });
|
|
1308
|
-
*
|
|
1309
|
-
* if (isLoading) return <div>Loading TTS...</div>;
|
|
1310
|
-
*
|
|
1311
|
-
* return (
|
|
1312
|
-
* <div>
|
|
1313
|
-
* <select onChange={e => setVoice(e.target.value)}>
|
|
1314
|
-
* {listVoices().map(v => (
|
|
1315
|
-
* <option key={v.id} value={v.id}>{v.name}</option>
|
|
1316
|
-
* ))}
|
|
1317
|
-
* </select>
|
|
1318
|
-
* <button onClick={() => speak("Hello world!")}>
|
|
1319
|
-
* {isSpeaking ? "Speaking..." : "Speak"}
|
|
1320
|
-
* </button>
|
|
1321
|
-
* {isSpeaking && <button onClick={stop}>Stop</button>}
|
|
1322
|
-
* </div>
|
|
1323
|
-
* );
|
|
1324
|
-
* }
|
|
1325
|
-
* ```
|
|
132
|
+
* Platform-appropriate install guidance. iOS Safari has NO programmatic install
|
|
133
|
+
* prompt — installation is manual (Share → Add to Home Screen), so apps should
|
|
134
|
+
* show these instructions. Other platforms (Android/Chrome) fire
|
|
135
|
+
* `beforeinstallprompt`, which apps can capture for a one-tap button.
|
|
1326
136
|
*/
|
|
1327
|
-
function
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const [error, setError] = useState(null);
|
|
1339
|
-
const [shouldLoad, setShouldLoad] = useState(autoLoad);
|
|
1340
|
-
const [currentVoice, setCurrentVoice] = useState(defaultVoice);
|
|
1341
|
-
const [currentSpeed, setCurrentSpeed] = useState(defaultSpeed);
|
|
1342
|
-
const ttsRef = useRef(null);
|
|
1343
|
-
const voiceEmbeddingsRef = useRef(/* @__PURE__ */ new Map());
|
|
1344
|
-
const audioContextRef = useRef(null);
|
|
1345
|
-
const sourceNodeRef = useRef(null);
|
|
1346
|
-
const mountedRef = useRef(true);
|
|
1347
|
-
const modelIdRef = useRef(modelId);
|
|
1348
|
-
const listVoices = useCallback(() => {
|
|
1349
|
-
return modelConfig.voices;
|
|
1350
|
-
}, [modelConfig.voices]);
|
|
1351
|
-
const load = useCallback(() => {
|
|
1352
|
-
if (ttsRef.current || isLoading) return;
|
|
1353
|
-
setIsLoading(true);
|
|
1354
|
-
setShouldLoad(true);
|
|
1355
|
-
}, [isLoading]);
|
|
1356
|
-
useEffect(() => {
|
|
1357
|
-
if (!shouldLoad) return;
|
|
1358
|
-
mountedRef.current = true;
|
|
1359
|
-
modelIdRef.current = modelId;
|
|
1360
|
-
const initTTS = async () => {
|
|
1361
|
-
try {
|
|
1362
|
-
const isSupertonic = modelId === "supertonic-66m";
|
|
1363
|
-
const config = TTS_MODELS[modelId];
|
|
1364
|
-
setLoadingProgress({
|
|
1365
|
-
status: "loading",
|
|
1366
|
-
message: `Loading ${isSupertonic ? "Supertonic" : "Kokoro"} TTS...`
|
|
1367
|
-
});
|
|
1368
|
-
if (isSupertonic) {
|
|
1369
|
-
const { pipeline } = await import("../transformers.web-u34VxRFM.js");
|
|
1370
|
-
const tts = await pipeline("text-to-speech", config.repo, {
|
|
1371
|
-
device: "webgpu",
|
|
1372
|
-
progress_callback: (progress) => {
|
|
1373
|
-
if (!mountedRef.current) return;
|
|
1374
|
-
if (progress.status === "progress" && progress.file) setLoadingProgress({
|
|
1375
|
-
status: "downloading",
|
|
1376
|
-
file: progress.file,
|
|
1377
|
-
progress: Math.round(progress.progress || 0)
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
});
|
|
1381
|
-
if (!mountedRef.current) return;
|
|
1382
|
-
const voicesUrl = `https://huggingface.co/${config.repo}/resolve/main/voices/`;
|
|
1383
|
-
const embeddingsMap = /* @__PURE__ */ new Map();
|
|
1384
|
-
await Promise.all(config.voices.map(async (voice) => {
|
|
1385
|
-
try {
|
|
1386
|
-
const response = await fetch(`${voicesUrl}${voice.id}.bin`);
|
|
1387
|
-
if (response.ok) {
|
|
1388
|
-
const buffer = await response.arrayBuffer();
|
|
1389
|
-
embeddingsMap.set(voice.id, new Float32Array(buffer));
|
|
1390
|
-
}
|
|
1391
|
-
} catch (e) {
|
|
1392
|
-
console.warn(`Failed to load voice embedding for ${voice.id}:`, e);
|
|
1393
|
-
}
|
|
1394
|
-
}));
|
|
1395
|
-
if (!mountedRef.current) return;
|
|
1396
|
-
try {
|
|
1397
|
-
await tts("Hello", {
|
|
1398
|
-
speaker_embeddings: new Float32Array(12928),
|
|
1399
|
-
num_inference_steps: 1,
|
|
1400
|
-
speed: 1
|
|
1401
|
-
});
|
|
1402
|
-
} catch (e) {
|
|
1403
|
-
console.warn("Supertonic warmup failed:", e);
|
|
1404
|
-
}
|
|
1405
|
-
voiceEmbeddingsRef.current = embeddingsMap;
|
|
1406
|
-
ttsRef.current = {
|
|
1407
|
-
type: "supertonic",
|
|
1408
|
-
pipeline: tts,
|
|
1409
|
-
config
|
|
1410
|
-
};
|
|
1411
|
-
} else {
|
|
1412
|
-
const { KokoroTTS } = await import("../kokoro-CMOGDSgT.js");
|
|
1413
|
-
const tts = await KokoroTTS.from_pretrained(config.repo, {
|
|
1414
|
-
dtype: "fp32",
|
|
1415
|
-
progress_callback: (progress) => {
|
|
1416
|
-
if (!mountedRef.current) return;
|
|
1417
|
-
if (progress.status === "progress" && progress.file) setLoadingProgress({
|
|
1418
|
-
status: "downloading",
|
|
1419
|
-
file: progress.file,
|
|
1420
|
-
progress: Math.round(progress.progress || 0)
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
});
|
|
1424
|
-
if (!mountedRef.current) return;
|
|
1425
|
-
ttsRef.current = {
|
|
1426
|
-
type: "kokoro",
|
|
1427
|
-
instance: tts,
|
|
1428
|
-
config
|
|
1429
|
-
};
|
|
1430
|
-
}
|
|
1431
|
-
setIsLoading(false);
|
|
1432
|
-
setIsReady(true);
|
|
1433
|
-
setLoadingProgress({ status: "ready" });
|
|
1434
|
-
onReady?.();
|
|
1435
|
-
} catch (err) {
|
|
1436
|
-
if (!mountedRef.current) return;
|
|
1437
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1438
|
-
setError(errorMsg);
|
|
1439
|
-
setIsLoading(false);
|
|
1440
|
-
setLoadingProgress({
|
|
1441
|
-
status: "error",
|
|
1442
|
-
error: errorMsg
|
|
1443
|
-
});
|
|
1444
|
-
onError?.(errorMsg);
|
|
1445
|
-
}
|
|
1446
|
-
};
|
|
1447
|
-
initTTS();
|
|
1448
|
-
return () => {
|
|
1449
|
-
mountedRef.current = false;
|
|
1450
|
-
};
|
|
1451
|
-
}, [
|
|
1452
|
-
shouldLoad,
|
|
1453
|
-
modelId,
|
|
1454
|
-
onReady,
|
|
1455
|
-
onError
|
|
1456
|
-
]);
|
|
1457
|
-
useEffect(() => {
|
|
1458
|
-
return () => {
|
|
1459
|
-
try {
|
|
1460
|
-
sourceNodeRef.current?.stop();
|
|
1461
|
-
} catch {}
|
|
1462
|
-
try {
|
|
1463
|
-
if (audioContextRef.current && audioContextRef.current.state !== "closed") audioContextRef.current.close();
|
|
1464
|
-
} catch {}
|
|
1465
|
-
};
|
|
1466
|
-
}, []);
|
|
137
|
+
function getInstallGuidance() {
|
|
138
|
+
if (isStandalone()) return {
|
|
139
|
+
installed: true,
|
|
140
|
+
manual: false,
|
|
141
|
+
steps: "Already installed."
|
|
142
|
+
};
|
|
143
|
+
if (isIOS()) return {
|
|
144
|
+
installed: false,
|
|
145
|
+
manual: true,
|
|
146
|
+
steps: "Tap the Share button, then 'Add to Home Screen'. Installing unlocks durable storage so models download once instead of every visit."
|
|
147
|
+
};
|
|
1467
148
|
return {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
if (!ttsRef.current) {
|
|
1472
|
-
load();
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
|
-
try {
|
|
1476
|
-
setIsSpeaking(true);
|
|
1477
|
-
onStart?.();
|
|
1478
|
-
let audioData;
|
|
1479
|
-
let sampleRate;
|
|
1480
|
-
const ttsBackend = ttsRef.current;
|
|
1481
|
-
if (ttsBackend.type === "supertonic") {
|
|
1482
|
-
const config = ttsBackend.config;
|
|
1483
|
-
if (!config.voices.find((v) => v.id === voice)) {
|
|
1484
|
-
const validVoices = config.voices.map((v) => v.id).join(", ");
|
|
1485
|
-
throw new Error(`Voice "${voice}" not found. Should be one of: ${validVoices}.`);
|
|
1486
|
-
}
|
|
1487
|
-
let speakerEmbedding = voiceEmbeddingsRef.current.get(voice);
|
|
1488
|
-
if (!speakerEmbedding) try {
|
|
1489
|
-
const voiceUrl = `https://huggingface.co/${config.repo}/resolve/main/voices/${voice}.bin`;
|
|
1490
|
-
const response = await fetch(voiceUrl);
|
|
1491
|
-
if (response.ok) {
|
|
1492
|
-
const buffer = await response.arrayBuffer();
|
|
1493
|
-
speakerEmbedding = new Float32Array(buffer);
|
|
1494
|
-
voiceEmbeddingsRef.current.set(voice, speakerEmbedding);
|
|
1495
|
-
} else throw new Error(`Failed to load voice: ${response.status}`);
|
|
1496
|
-
} catch {
|
|
1497
|
-
speakerEmbedding = new Float32Array(12928).fill(.1);
|
|
1498
|
-
voiceEmbeddingsRef.current.set(voice, speakerEmbedding);
|
|
1499
|
-
}
|
|
1500
|
-
const result = await ttsBackend.pipeline(text, {
|
|
1501
|
-
speaker_embeddings: speakerEmbedding,
|
|
1502
|
-
speed
|
|
1503
|
-
});
|
|
1504
|
-
audioData = result.audio;
|
|
1505
|
-
sampleRate = result.sampling_rate;
|
|
1506
|
-
} else {
|
|
1507
|
-
const config = ttsBackend.config;
|
|
1508
|
-
if (!config.voices.find((v) => v.id === voice)) {
|
|
1509
|
-
const validVoices = config.voices.map((v) => v.id).join(", ");
|
|
1510
|
-
throw new Error(`Voice "${voice}" not found. Should be one of: ${validVoices}.`);
|
|
1511
|
-
}
|
|
1512
|
-
const result = await ttsBackend.instance.generate(text, {
|
|
1513
|
-
voice,
|
|
1514
|
-
speed
|
|
1515
|
-
});
|
|
1516
|
-
audioData = result.audio;
|
|
1517
|
-
sampleRate = result.sampling_rate;
|
|
1518
|
-
}
|
|
1519
|
-
if (!mountedRef.current) return;
|
|
1520
|
-
if (!audioContextRef.current || audioContextRef.current.state === "closed") audioContextRef.current = new AudioContext();
|
|
1521
|
-
const audioContext = audioContextRef.current;
|
|
1522
|
-
if (audioContext.state === "suspended") await audioContext.resume();
|
|
1523
|
-
const audioBuffer = audioContext.createBuffer(1, audioData.length, sampleRate);
|
|
1524
|
-
const channelData = new Float32Array(audioData);
|
|
1525
|
-
audioBuffer.copyToChannel(channelData, 0);
|
|
1526
|
-
if (sourceNodeRef.current) {
|
|
1527
|
-
sourceNodeRef.current.stop();
|
|
1528
|
-
sourceNodeRef.current.disconnect();
|
|
1529
|
-
}
|
|
1530
|
-
const sourceNode = audioContext.createBufferSource();
|
|
1531
|
-
sourceNode.buffer = audioBuffer;
|
|
1532
|
-
sourceNode.connect(audioContext.destination);
|
|
1533
|
-
sourceNode.onended = () => {
|
|
1534
|
-
if (mountedRef.current) {
|
|
1535
|
-
setIsSpeaking(false);
|
|
1536
|
-
onEnd?.();
|
|
1537
|
-
}
|
|
1538
|
-
};
|
|
1539
|
-
sourceNodeRef.current = sourceNode;
|
|
1540
|
-
sourceNode.start();
|
|
1541
|
-
} catch (err) {
|
|
1542
|
-
if (!mountedRef.current) return;
|
|
1543
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1544
|
-
setError(errorMsg);
|
|
1545
|
-
setIsSpeaking(false);
|
|
1546
|
-
onError?.(errorMsg);
|
|
1547
|
-
}
|
|
1548
|
-
}, [
|
|
1549
|
-
currentVoice,
|
|
1550
|
-
currentSpeed,
|
|
1551
|
-
load,
|
|
1552
|
-
onStart,
|
|
1553
|
-
onEnd,
|
|
1554
|
-
onError
|
|
1555
|
-
]),
|
|
1556
|
-
stop: useCallback(() => {
|
|
1557
|
-
if (sourceNodeRef.current) {
|
|
1558
|
-
sourceNodeRef.current.stop();
|
|
1559
|
-
sourceNodeRef.current.disconnect();
|
|
1560
|
-
sourceNodeRef.current = null;
|
|
1561
|
-
}
|
|
1562
|
-
setIsSpeaking(false);
|
|
1563
|
-
}, []),
|
|
1564
|
-
isLoading,
|
|
1565
|
-
loadingProgress,
|
|
1566
|
-
isSpeaking,
|
|
1567
|
-
isReady,
|
|
1568
|
-
load,
|
|
1569
|
-
error,
|
|
1570
|
-
listVoices,
|
|
1571
|
-
currentVoice,
|
|
1572
|
-
setVoice: useCallback((voiceId) => {
|
|
1573
|
-
if (modelConfig.voices.find((v) => v.id === voiceId)) setCurrentVoice(voiceId);
|
|
1574
|
-
else console.warn(`Voice "${voiceId}" not valid for ${modelId}. Available: ${modelConfig.voices.map((v) => v.id).join(", ")}`);
|
|
1575
|
-
}, [modelConfig.voices, modelId]),
|
|
1576
|
-
currentSpeed,
|
|
1577
|
-
setSpeed: useCallback((speed) => {
|
|
1578
|
-
setCurrentSpeed(Math.max(.5, Math.min(2, speed)));
|
|
1579
|
-
}, []),
|
|
1580
|
-
currentModel: modelId,
|
|
1581
|
-
sampleRate: modelConfig.sampleRate
|
|
149
|
+
installed: false,
|
|
150
|
+
manual: false,
|
|
151
|
+
steps: "Use your browser's Install option (or the install icon in the address bar) to add this app for offline use and durable model storage."
|
|
1582
152
|
};
|
|
1583
153
|
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/browser/audio.ts
|
|
1584
157
|
/**
|
|
1585
158
|
* Play audio from Float32Array using Web Audio API
|
|
1586
159
|
*
|
|
@@ -1673,819 +246,504 @@ function createAudioPlayer(sampleRate = 24e3) {
|
|
|
1673
246
|
isPlaying: () => isActive
|
|
1674
247
|
};
|
|
1675
248
|
}
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/browser/device-guards.ts
|
|
252
|
+
/** Recommended safe model ids per modality (used as fallbacks on mobile). */
|
|
253
|
+
const SAFE_MOBILE_CHAT = "mlx-community/Qwen3.5-0.8B-4bit";
|
|
1676
254
|
/**
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
* Uses MediaRecorder to capture audio and Whisper for transcription.
|
|
1680
|
-
* Supports both one-shot and streaming transcription modes.
|
|
1681
|
-
*
|
|
1682
|
-
* @example Basic usage (one-shot)
|
|
1683
|
-
* ```tsx
|
|
1684
|
-
* function VoiceInput() {
|
|
1685
|
-
* const { startRecording, stopRecording, isRecording, transcript } = useVoiceInput({
|
|
1686
|
-
* onTranscript: (text) => console.log("User said:", text),
|
|
1687
|
-
* });
|
|
1688
|
-
*
|
|
1689
|
-
* return (
|
|
1690
|
-
* <button onClick={isRecording ? stopRecording : startRecording}>
|
|
1691
|
-
* {isRecording ? "Stop" : "Record"}
|
|
1692
|
-
* </button>
|
|
1693
|
-
* );
|
|
1694
|
-
* }
|
|
1695
|
-
* ```
|
|
1696
|
-
*
|
|
1697
|
-
* @example Streaming transcription (real-time)
|
|
1698
|
-
* ```tsx
|
|
1699
|
-
* function LiveTranscription() {
|
|
1700
|
-
* const { startRecording, stopRecording, isRecording, transcript, streamingChunk } = useVoiceInput({
|
|
1701
|
-
* streaming: true, // Enable streaming mode
|
|
1702
|
-
* chunkDuration: 1500, // Transcribe every 1.5 seconds (default)
|
|
1703
|
-
* onChunk: (text, idx) => console.log(`Chunk ${idx}: ${text}`),
|
|
1704
|
-
* });
|
|
1705
|
-
*
|
|
1706
|
-
* return (
|
|
1707
|
-
* <div>
|
|
1708
|
-
* <button onClick={isRecording ? stopRecording : startRecording}>
|
|
1709
|
-
* {isRecording ? "Stop" : "Start Live Transcription"}
|
|
1710
|
-
* </button>
|
|
1711
|
-
* <p>Current chunk: {streamingChunk}</p>
|
|
1712
|
-
* <p>Full transcript: {transcript}</p>
|
|
1713
|
-
* </div>
|
|
1714
|
-
* );
|
|
1715
|
-
* }
|
|
1716
|
-
* ```
|
|
255
|
+
* Approximate on-device (INT4) memory footprint in MB for the models the native
|
|
256
|
+
* engine actually ships. Used for memory-aware selection and messaging.
|
|
1717
257
|
*/
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
onProgress?.({
|
|
1789
|
-
status: "error",
|
|
1790
|
-
message: errMsg
|
|
1791
|
-
});
|
|
1792
|
-
onError?.(errMsg);
|
|
1793
|
-
}
|
|
258
|
+
const MODEL_SIZES = {
|
|
259
|
+
"qwen3.5-0.8b": 650,
|
|
260
|
+
"qwen3.5-2b": 1700,
|
|
261
|
+
"gemma-4-e2b": 3600,
|
|
262
|
+
"lfm2.5-350m": 300,
|
|
263
|
+
"kokoro-82m": 350,
|
|
264
|
+
"supertonic-66m": 300,
|
|
265
|
+
"whisper-tiny": 150,
|
|
266
|
+
"whisper-tiny.en": 150,
|
|
267
|
+
"whisper-small": 500,
|
|
268
|
+
"all-minilm-l6-v2": 100
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Normalize a repo/model id to a lowercase token stream for substring matching
|
|
272
|
+
* (strips org prefixes' punctuation while preserving the model name tokens).
|
|
273
|
+
*/
|
|
274
|
+
function normalizeId(modelId) {
|
|
275
|
+
return modelId.toLowerCase().replace(/[^a-z0-9]/g, "-");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* iOS (WKWebView) model classification keyed off the REAL native-engine ids.
|
|
279
|
+
*
|
|
280
|
+
* - blocked: too large for the WKWebView memory ceiling on iPhone — will crash.
|
|
281
|
+
* gemma-4-e2b (~3.6GB) plus any vision checkpoint (the vision encoder pushes
|
|
282
|
+
* the working set well past what an iPhone can hold).
|
|
283
|
+
* - risky: Qwen3.5-2B (~1.7GB) OOM-crashes iPhone WKWebView in practice — now
|
|
284
|
+
* treated as unsafe to auto-load (flagged `risky` so a UI can offer to force it).
|
|
285
|
+
* - everything else (Qwen3.5-0.8B ~0.65GB, LFM2.5-350M) is allowed everywhere.
|
|
286
|
+
*/
|
|
287
|
+
const IOS_MODEL_LIMITS = {
|
|
288
|
+
blocked: ["gemma-4-e2b", "gemma-4-e4b"],
|
|
289
|
+
visionMarkers: [
|
|
290
|
+
"vision",
|
|
291
|
+
"-vl-",
|
|
292
|
+
"-vl",
|
|
293
|
+
"vlm",
|
|
294
|
+
"image-text",
|
|
295
|
+
"-it-vision"
|
|
296
|
+
],
|
|
297
|
+
risky: ["qwen3.5-2b", "qwen3-5-2b"],
|
|
298
|
+
maxBudgetMB: 1800
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Check if a model is safe to load on the current device.
|
|
302
|
+
* Returns guidance specific to iOS memory constraints. Matches on the real
|
|
303
|
+
* native-engine repo ids (MLX 4-bit / upstream Qwen / Liquid).
|
|
304
|
+
*/
|
|
305
|
+
function isModelSafeForDevice(modelId) {
|
|
306
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
307
|
+
const isIPhone = /iPhone|iPod/.test(ua);
|
|
308
|
+
const isIPad = /iPad/.test(ua);
|
|
309
|
+
const isIOSChrome = (isIPhone || isIPad) && /CriOS/.test(ua);
|
|
310
|
+
const normalizedId = normalizeId(modelId);
|
|
311
|
+
const isVision = IOS_MODEL_LIMITS.visionMarkers.some((m) => normalizedId.includes(normalizeId(m)));
|
|
312
|
+
const isBlocked = IOS_MODEL_LIMITS.blocked.some((m) => normalizedId.includes(normalizeId(m))) || isVision;
|
|
313
|
+
const isRisky = IOS_MODEL_LIMITS.risky.some((m) => normalizedId.includes(normalizeId(m)));
|
|
314
|
+
if (isIPhone) {
|
|
315
|
+
if (isBlocked) return {
|
|
316
|
+
safe: false,
|
|
317
|
+
risky: false,
|
|
318
|
+
reason: `Model ${modelId} will crash on iPhone${isIOSChrome ? " (iOS Chrome uses WKWebView, same limits as Safari)" : ""}. ${isVision ? "Vision checkpoints need a separate image encoder in memory" : "It is too large (~3.6GB)"}, which exceeds the WKWebView memory ceiling.`,
|
|
319
|
+
recommendation: `Use ${SAFE_MOBILE_CHAT} (Qwen3.5-0.8B) on iPhone, or run larger models on desktop.`,
|
|
320
|
+
maxSafeModel: SAFE_MOBILE_CHAT
|
|
321
|
+
};
|
|
322
|
+
if (isRisky) return {
|
|
323
|
+
safe: false,
|
|
324
|
+
risky: true,
|
|
325
|
+
reason: `Model ${modelId} (~1.7GB) exceeds the iPhone WKWebView memory budget and is likely to crash.`,
|
|
326
|
+
recommendation: `Use ${SAFE_MOBILE_CHAT} (Qwen3.5-0.8B) on iPhone; run Qwen3.5-2B on iPad or desktop.`,
|
|
327
|
+
maxSafeModel: SAFE_MOBILE_CHAT
|
|
1794
328
|
};
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
329
|
+
return {
|
|
330
|
+
safe: true,
|
|
331
|
+
risky: false,
|
|
332
|
+
reason: "Model is within iPhone memory limits."
|
|
1798
333
|
};
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
useEffect(() => {
|
|
1808
|
-
mountedRef.current = true;
|
|
1809
|
-
return () => {
|
|
1810
|
-
mountedRef.current = false;
|
|
1811
|
-
if (sttRef.current) sttRef.current.dispose();
|
|
1812
|
-
if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop();
|
|
334
|
+
}
|
|
335
|
+
if (isIPad) {
|
|
336
|
+
if (isBlocked) return {
|
|
337
|
+
safe: false,
|
|
338
|
+
risky: false,
|
|
339
|
+
reason: `Model ${modelId} may crash on iPad. ${isVision ? "Vision checkpoints need a separate image encoder in memory" : "It is too large (~3.6GB)"}, which can exceed the WKWebView memory ceiling.`,
|
|
340
|
+
recommendation: `Use ${SAFE_MOBILE_CHAT} (Qwen3.5-0.8B) or Qwen3.5-2B on iPad.`,
|
|
341
|
+
maxSafeModel: "mlx-community/Qwen3.5-2B-4bit"
|
|
1813
342
|
};
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
if (audioBuffer.sampleRate !== 16e3) {
|
|
1828
|
-
const ratio = 16e3 / audioBuffer.sampleRate;
|
|
1829
|
-
const newLength = Math.round(channelData.length * ratio);
|
|
1830
|
-
const resampled = new Float32Array(newLength);
|
|
1831
|
-
for (let i = 0; i < newLength; i++) {
|
|
1832
|
-
const srcIndex = i / ratio;
|
|
1833
|
-
const floor = Math.floor(srcIndex);
|
|
1834
|
-
const ceil = Math.min(floor + 1, channelData.length - 1);
|
|
1835
|
-
const t = srcIndex - floor;
|
|
1836
|
-
resampled[i] = channelData[floor] * (1 - t) + channelData[ceil] * t;
|
|
1837
|
-
}
|
|
1838
|
-
audioContext.close();
|
|
1839
|
-
return resampled;
|
|
1840
|
-
}
|
|
1841
|
-
audioContext.close();
|
|
1842
|
-
return new Float32Array(channelData);
|
|
1843
|
-
}, []);
|
|
1844
|
-
const transcribe = useCallback(async (audio) => {
|
|
1845
|
-
if (!sttRef.current) {
|
|
1846
|
-
if (!shouldLoad) {
|
|
1847
|
-
setShouldLoad(true);
|
|
1848
|
-
throw new Error("STT model not loaded. Loading now, please try again.");
|
|
1849
|
-
}
|
|
1850
|
-
throw new Error("STT model not loaded");
|
|
1851
|
-
}
|
|
1852
|
-
setIsTranscribing(true);
|
|
1853
|
-
try {
|
|
1854
|
-
let text = (await sttRef.current.transcribe(audio)).text.trim();
|
|
1855
|
-
if (text === "[BLANK_AUDIO]" || text === "(blank audio)" || text === "[BLANK AUDIO]") text = "";
|
|
1856
|
-
setTranscript(text);
|
|
1857
|
-
onTranscript?.(text);
|
|
1858
|
-
return text;
|
|
1859
|
-
} finally {
|
|
1860
|
-
if (mountedRef.current) setIsTranscribing(false);
|
|
1861
|
-
}
|
|
1862
|
-
}, [shouldLoad, onTranscript]);
|
|
1863
|
-
const processedSamplesRef = useRef(0);
|
|
1864
|
-
const transcribeChunk = useCallback(async (chunkIdx) => {
|
|
1865
|
-
if (!sttRef.current || audioChunksRef.current.length === 0) return "";
|
|
1866
|
-
try {
|
|
1867
|
-
const audioData = await blobToFloat32(new Blob(audioChunksRef.current, { type: "audio/webm" }));
|
|
1868
|
-
const newSamplesStart = processedSamplesRef.current;
|
|
1869
|
-
const totalSamples = audioData.length;
|
|
1870
|
-
if (totalSamples - newSamplesStart < 8e3) return "";
|
|
1871
|
-
const newAudio = audioData.slice(newSamplesStart);
|
|
1872
|
-
processedSamplesRef.current = totalSamples;
|
|
1873
|
-
let text = (await sttRef.current.transcribe(newAudio)).text.trim();
|
|
1874
|
-
if (text === "[BLANK_AUDIO]" || text === "(blank audio)" || text === "[BLANK AUDIO]") text = "";
|
|
1875
|
-
if (text && mountedRef.current) {
|
|
1876
|
-
setStreamingChunk(text);
|
|
1877
|
-
onChunk?.(text, chunkIdx);
|
|
1878
|
-
}
|
|
1879
|
-
return text;
|
|
1880
|
-
} catch {
|
|
1881
|
-
return "";
|
|
1882
|
-
}
|
|
1883
|
-
}, [blobToFloat32, onChunk]);
|
|
343
|
+
return {
|
|
344
|
+
safe: true,
|
|
345
|
+
risky: false,
|
|
346
|
+
reason: "Model is within iPad memory limits."
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (/Android/.test(ua) && isBlocked) return {
|
|
350
|
+
safe: false,
|
|
351
|
+
risky: false,
|
|
352
|
+
reason: `Model ${modelId} is very large and may crash on Android devices.`,
|
|
353
|
+
recommendation: `Use ${SAFE_MOBILE_CHAT} (Qwen3.5-0.8B) or Qwen3.5-2B on Android.`,
|
|
354
|
+
maxSafeModel: "mlx-community/Qwen3.5-2B-4bit"
|
|
355
|
+
};
|
|
1884
356
|
return {
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
if (streaming && !sttRef.current) {
|
|
1889
|
-
if (!shouldLoad) setShouldLoad(true);
|
|
1890
|
-
setIsLoading(true);
|
|
1891
|
-
const { WhisperSTT } = await import("../stt-Bu-E23Sc.js");
|
|
1892
|
-
const stt = new WhisperSTT(model);
|
|
1893
|
-
await stt.load({ onProgress: (p) => {
|
|
1894
|
-
if (mountedRef.current) {
|
|
1895
|
-
const progress = {
|
|
1896
|
-
status: p.status === "downloading" ? "downloading" : p.status === "ready" ? "ready" : "loading",
|
|
1897
|
-
message: p.status,
|
|
1898
|
-
progress: p.progress,
|
|
1899
|
-
file: p.file
|
|
1900
|
-
};
|
|
1901
|
-
setLoadingProgress(progress);
|
|
1902
|
-
onProgress?.(progress);
|
|
1903
|
-
}
|
|
1904
|
-
} });
|
|
1905
|
-
if (!mountedRef.current) {
|
|
1906
|
-
stt.dispose();
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
sttRef.current = stt;
|
|
1910
|
-
setIsReady(true);
|
|
1911
|
-
setIsLoading(false);
|
|
1912
|
-
setLoadingProgress({ status: "ready" });
|
|
1913
|
-
onProgress?.({ status: "ready" });
|
|
1914
|
-
onReady?.();
|
|
1915
|
-
}
|
|
1916
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: {
|
|
1917
|
-
sampleRate: 16e3,
|
|
1918
|
-
channelCount: 1,
|
|
1919
|
-
echoCancellation: true,
|
|
1920
|
-
noiseSuppression: true
|
|
1921
|
-
} });
|
|
1922
|
-
streamRef.current = stream;
|
|
1923
|
-
audioChunksRef.current = [];
|
|
1924
|
-
pendingChunksRef.current = [];
|
|
1925
|
-
fullTranscriptRef.current = "";
|
|
1926
|
-
processedSamplesRef.current = 0;
|
|
1927
|
-
setTranscript("");
|
|
1928
|
-
setStreamingChunk("");
|
|
1929
|
-
setChunkCount(0);
|
|
1930
|
-
const mediaRecorder = new MediaRecorder(stream);
|
|
1931
|
-
mediaRecorderRef.current = mediaRecorder;
|
|
1932
|
-
mediaRecorder.ondataavailable = (event) => {
|
|
1933
|
-
if (event.data.size > 0) {
|
|
1934
|
-
audioChunksRef.current.push(event.data);
|
|
1935
|
-
if (streaming) pendingChunksRef.current.push(event.data);
|
|
1936
|
-
}
|
|
1937
|
-
};
|
|
1938
|
-
mediaRecorder.start(100);
|
|
1939
|
-
setIsRecording(true);
|
|
1940
|
-
setError(null);
|
|
1941
|
-
if (streaming && sttRef.current) {
|
|
1942
|
-
let chunkIdx = 0;
|
|
1943
|
-
let shouldContinue = true;
|
|
1944
|
-
const processNextChunk = async () => {
|
|
1945
|
-
if (!shouldContinue || !mountedRef.current) return;
|
|
1946
|
-
if (pendingChunksRef.current.length > 0) {
|
|
1947
|
-
pendingChunksRef.current = [];
|
|
1948
|
-
try {
|
|
1949
|
-
setIsTranscribing(true);
|
|
1950
|
-
const chunkText = await transcribeChunk(chunkIdx);
|
|
1951
|
-
if (chunkText && mountedRef.current) {
|
|
1952
|
-
chunkIdx++;
|
|
1953
|
-
setChunkCount(chunkIdx);
|
|
1954
|
-
setTranscript((prev) => {
|
|
1955
|
-
const newTranscript = prev + (prev ? " " : "") + chunkText;
|
|
1956
|
-
fullTranscriptRef.current = newTranscript;
|
|
1957
|
-
onTranscript?.(newTranscript);
|
|
1958
|
-
return newTranscript;
|
|
1959
|
-
});
|
|
1960
|
-
}
|
|
1961
|
-
} catch (e) {
|
|
1962
|
-
console.error("[useVoiceInput] Chunk transcription error:", e);
|
|
1963
|
-
} finally {
|
|
1964
|
-
if (mountedRef.current) setIsTranscribing(false);
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
if (shouldContinue && mountedRef.current) streamingIntervalRef.current = setTimeout(processNextChunk, chunkDuration);
|
|
1968
|
-
};
|
|
1969
|
-
streamingIntervalRef.current = setTimeout(processNextChunk, chunkDuration);
|
|
1970
|
-
streamingIntervalRef._stop = () => {
|
|
1971
|
-
shouldContinue = false;
|
|
1972
|
-
};
|
|
1973
|
-
}
|
|
1974
|
-
} catch (e) {
|
|
1975
|
-
const errMsg = e.message || "Failed to start recording";
|
|
1976
|
-
setError(errMsg);
|
|
1977
|
-
onError?.(errMsg);
|
|
1978
|
-
}
|
|
1979
|
-
}, [
|
|
1980
|
-
isRecording,
|
|
1981
|
-
streaming,
|
|
1982
|
-
shouldLoad,
|
|
1983
|
-
model,
|
|
1984
|
-
chunkDuration,
|
|
1985
|
-
transcribeChunk,
|
|
1986
|
-
onTranscript,
|
|
1987
|
-
onError,
|
|
1988
|
-
onProgress,
|
|
1989
|
-
onReady
|
|
1990
|
-
]),
|
|
1991
|
-
stopRecording: useCallback(async () => {
|
|
1992
|
-
if (streamingIntervalRef._stop) streamingIntervalRef._stop();
|
|
1993
|
-
if (streamingIntervalRef.current) {
|
|
1994
|
-
clearTimeout(streamingIntervalRef.current);
|
|
1995
|
-
streamingIntervalRef.current = null;
|
|
1996
|
-
}
|
|
1997
|
-
return new Promise((resolve, reject) => {
|
|
1998
|
-
if (!mediaRecorderRef.current || !isRecording) {
|
|
1999
|
-
reject(/* @__PURE__ */ new Error("Not recording"));
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
const mediaRecorder = mediaRecorderRef.current;
|
|
2003
|
-
mediaRecorder.onstop = async () => {
|
|
2004
|
-
if (streamRef.current) {
|
|
2005
|
-
for (const track of streamRef.current.getTracks()) track.stop();
|
|
2006
|
-
streamRef.current = null;
|
|
2007
|
-
}
|
|
2008
|
-
setIsRecording(false);
|
|
2009
|
-
if (streaming) {
|
|
2010
|
-
if (audioChunksRef.current.length > 0 && processedSamplesRef.current > 0) {
|
|
2011
|
-
setIsTranscribing(true);
|
|
2012
|
-
pendingChunksRef.current = [];
|
|
2013
|
-
try {
|
|
2014
|
-
const finalChunkText = await transcribeChunk(chunkCount);
|
|
2015
|
-
if (finalChunkText && mountedRef.current) setTranscript((prev) => {
|
|
2016
|
-
const newTranscript = prev + (prev ? " " : "") + finalChunkText;
|
|
2017
|
-
fullTranscriptRef.current = newTranscript;
|
|
2018
|
-
return newTranscript;
|
|
2019
|
-
});
|
|
2020
|
-
} finally {
|
|
2021
|
-
if (mountedRef.current) setIsTranscribing(false);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
const finalText = fullTranscriptRef.current;
|
|
2025
|
-
onTranscript?.(finalText);
|
|
2026
|
-
resolve(finalText);
|
|
2027
|
-
return;
|
|
2028
|
-
}
|
|
2029
|
-
const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
|
2030
|
-
try {
|
|
2031
|
-
if (!sttRef.current) {
|
|
2032
|
-
if (!shouldLoad) setShouldLoad(true);
|
|
2033
|
-
await new Promise((res, rej) => {
|
|
2034
|
-
const checkReady = setInterval(() => {
|
|
2035
|
-
if (sttRef.current) {
|
|
2036
|
-
clearInterval(checkReady);
|
|
2037
|
-
res();
|
|
2038
|
-
}
|
|
2039
|
-
}, 100);
|
|
2040
|
-
setTimeout(() => {
|
|
2041
|
-
clearInterval(checkReady);
|
|
2042
|
-
rej(/* @__PURE__ */ new Error("Timeout waiting for STT model"));
|
|
2043
|
-
}, 3e4);
|
|
2044
|
-
});
|
|
2045
|
-
}
|
|
2046
|
-
resolve(await transcribe(await blobToFloat32(audioBlob)));
|
|
2047
|
-
} catch (e) {
|
|
2048
|
-
const errMsg = e.message || "Transcription failed";
|
|
2049
|
-
setError(errMsg);
|
|
2050
|
-
onError?.(errMsg);
|
|
2051
|
-
reject(e);
|
|
2052
|
-
}
|
|
2053
|
-
};
|
|
2054
|
-
mediaRecorder.stop();
|
|
2055
|
-
});
|
|
2056
|
-
}, [
|
|
2057
|
-
isRecording,
|
|
2058
|
-
streaming,
|
|
2059
|
-
chunkCount,
|
|
2060
|
-
shouldLoad,
|
|
2061
|
-
blobToFloat32,
|
|
2062
|
-
transcribe,
|
|
2063
|
-
transcribeChunk,
|
|
2064
|
-
onTranscript,
|
|
2065
|
-
onError
|
|
2066
|
-
]),
|
|
2067
|
-
cancelRecording: useCallback(() => {
|
|
2068
|
-
if (streamingIntervalRef._stop) streamingIntervalRef._stop();
|
|
2069
|
-
if (streamingIntervalRef.current) {
|
|
2070
|
-
clearTimeout(streamingIntervalRef.current);
|
|
2071
|
-
streamingIntervalRef.current = null;
|
|
2072
|
-
}
|
|
2073
|
-
if (mediaRecorderRef.current && isRecording) mediaRecorderRef.current.stop();
|
|
2074
|
-
if (streamRef.current) {
|
|
2075
|
-
for (const track of streamRef.current.getTracks()) track.stop();
|
|
2076
|
-
streamRef.current = null;
|
|
2077
|
-
}
|
|
2078
|
-
audioChunksRef.current = [];
|
|
2079
|
-
pendingChunksRef.current = [];
|
|
2080
|
-
processedSamplesRef.current = 0;
|
|
2081
|
-
setIsRecording(false);
|
|
2082
|
-
}, [isRecording]),
|
|
2083
|
-
transcribe,
|
|
2084
|
-
isRecording,
|
|
2085
|
-
isTranscribing,
|
|
2086
|
-
isLoading,
|
|
2087
|
-
isReady,
|
|
2088
|
-
transcript,
|
|
2089
|
-
streamingChunk,
|
|
2090
|
-
chunkCount,
|
|
2091
|
-
loadingProgress,
|
|
2092
|
-
error,
|
|
2093
|
-
load
|
|
357
|
+
safe: true,
|
|
358
|
+
risky: false,
|
|
359
|
+
reason: "Desktop browser has sufficient memory."
|
|
2094
360
|
};
|
|
2095
361
|
}
|
|
2096
362
|
/**
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
*
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
363
|
+
* Get recommended models based on device memory and capabilities.
|
|
364
|
+
* Helps prevent OOM crashes on low-memory mobile devices.
|
|
365
|
+
*/
|
|
366
|
+
function getRecommendedModels() {
|
|
367
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
368
|
+
const deviceMemory = typeof navigator !== "undefined" ? navigator.deviceMemory : null;
|
|
369
|
+
const isMobile = /iPhone|iPad|iPod|Android|Mobile/.test(ua);
|
|
370
|
+
const availableMB = (deviceMemory ? isMobile ? deviceMemory * .4 : deviceMemory * .6 : 4) * 1024;
|
|
371
|
+
let chat;
|
|
372
|
+
let reason;
|
|
373
|
+
if (availableMB < 600) {
|
|
374
|
+
chat = "LiquidAI/LFM2.5-350M";
|
|
375
|
+
reason = "Very low memory device - using smallest model (LFM2.5-350M)";
|
|
376
|
+
} else if (isMobile && availableMB < 2200) {
|
|
377
|
+
chat = SAFE_MOBILE_CHAT;
|
|
378
|
+
reason = "Mobile device - using Qwen3.5-0.8B to stay within the WKWebView memory limit";
|
|
379
|
+
} else if (availableMB < 2200) {
|
|
380
|
+
chat = SAFE_MOBILE_CHAT;
|
|
381
|
+
reason = "Standard model for moderate memory (Qwen3.5-0.8B)";
|
|
382
|
+
} else {
|
|
383
|
+
chat = "mlx-community/Qwen3.5-2B-4bit";
|
|
384
|
+
reason = "High memory available - using Qwen3.5-2B for better quality";
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
chat,
|
|
388
|
+
tts: "kokoro-82m",
|
|
389
|
+
stt: "whisper-tiny.en",
|
|
390
|
+
embedding: "all-MiniLM-L6-v2",
|
|
391
|
+
reason,
|
|
392
|
+
deviceMemory,
|
|
393
|
+
isMobile
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const SESSION_STORAGE_KEY = "gerbil_session_phase";
|
|
397
|
+
/**
|
|
398
|
+
* Generate a unique session ID for tracking across reloads.
|
|
399
|
+
*/
|
|
400
|
+
function generateSessionId() {
|
|
401
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Get or create the current session ID.
|
|
405
|
+
*/
|
|
406
|
+
function getSessionId() {
|
|
407
|
+
if (typeof localStorage === "undefined") return generateSessionId();
|
|
408
|
+
let sessionId = sessionStorage.getItem("gerbil_session_id");
|
|
409
|
+
if (!sessionId) {
|
|
410
|
+
sessionId = generateSessionId();
|
|
411
|
+
sessionStorage.setItem("gerbil_session_id", sessionId);
|
|
412
|
+
}
|
|
413
|
+
return sessionId;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Set the current download/initialization phase.
|
|
417
|
+
* Used to detect if a reload happened during a critical operation.
|
|
418
|
+
*/
|
|
419
|
+
function setDownloadPhase(phase, modelId, progress) {
|
|
420
|
+
if (typeof localStorage === "undefined") return;
|
|
421
|
+
const state = {
|
|
422
|
+
phase,
|
|
423
|
+
modelId: modelId || null,
|
|
424
|
+
sessionId: getSessionId(),
|
|
425
|
+
timestamp: Date.now(),
|
|
426
|
+
bytesDownloaded: progress?.bytesDownloaded,
|
|
427
|
+
totalBytes: progress?.totalBytes
|
|
428
|
+
};
|
|
429
|
+
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Get the last known download phase from storage.
|
|
433
|
+
*/
|
|
434
|
+
function getDownloadPhase() {
|
|
435
|
+
if (typeof localStorage === "undefined") return null;
|
|
436
|
+
try {
|
|
437
|
+
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
|
438
|
+
if (!raw) return null;
|
|
439
|
+
return JSON.parse(raw);
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Detect if the page reloaded during a model download/initialization.
|
|
446
|
+
* This typically indicates an iOS memory crash.
|
|
2122
447
|
*
|
|
2123
|
-
*
|
|
2124
|
-
* <div>
|
|
2125
|
-
* {messages.map(m => (
|
|
2126
|
-
* <div key={m.id}>{m.role}: {m.content}</div>
|
|
2127
|
-
* ))}
|
|
2128
|
-
* <button
|
|
2129
|
-
* onMouseDown={startListening}
|
|
2130
|
-
* onMouseUp={stopListening}
|
|
2131
|
-
* >
|
|
2132
|
-
* {stage === "idle" ? "🎤 Hold to Speak" : stage}
|
|
2133
|
-
* </button>
|
|
2134
|
-
* </div>
|
|
2135
|
-
* );
|
|
2136
|
-
* }
|
|
2137
|
-
* ```
|
|
448
|
+
* @returns Detection result with recommended action
|
|
2138
449
|
*/
|
|
2139
|
-
function
|
|
2140
|
-
const
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
const
|
|
2149
|
-
const
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const streamRef = useRef(null);
|
|
2159
|
-
const audioContextRef = useRef(null);
|
|
2160
|
-
const sourceNodeRef = useRef(null);
|
|
2161
|
-
const mountedRef = useRef(true);
|
|
2162
|
-
const cancelledRef = useRef(false);
|
|
2163
|
-
const isListening = stage === "listening";
|
|
2164
|
-
const isProcessing = stage === "transcribing" || stage === "thinking";
|
|
2165
|
-
const isSpeaking = stage === "speaking";
|
|
2166
|
-
useEffect(() => {
|
|
2167
|
-
if (!shouldLoad || isReady) return;
|
|
2168
|
-
let cancelled = false;
|
|
2169
|
-
const loadModels = async () => {
|
|
2170
|
-
try {
|
|
2171
|
-
setIsLoading(true);
|
|
2172
|
-
setError(null);
|
|
2173
|
-
setLoadingMessage("Loading speech recognition (Whisper)...");
|
|
2174
|
-
const { WhisperSTT } = await import("../stt-Bu-E23Sc.js");
|
|
2175
|
-
if (cancelled || !mountedRef.current) return;
|
|
2176
|
-
const stt = new WhisperSTT(sttModel);
|
|
2177
|
-
await stt.load({ onProgress: (p) => {
|
|
2178
|
-
if (!mountedRef.current) return;
|
|
2179
|
-
setLoadingMessage(p.status || "Loading STT...");
|
|
2180
|
-
} });
|
|
2181
|
-
if (cancelled || !mountedRef.current) {
|
|
2182
|
-
stt.dispose();
|
|
2183
|
-
return;
|
|
2184
|
-
}
|
|
2185
|
-
sttRef.current = stt;
|
|
2186
|
-
setLoadingMessage("Loading language model...");
|
|
2187
|
-
const worker = await createGerbilWorker({
|
|
2188
|
-
modelId: llmModel,
|
|
2189
|
-
onProgress: (p) => {
|
|
2190
|
-
if (!mountedRef.current) return;
|
|
2191
|
-
setLoadingMessage(p.message || "Loading LLM...");
|
|
2192
|
-
}
|
|
2193
|
-
});
|
|
2194
|
-
if (cancelled || !mountedRef.current) {
|
|
2195
|
-
worker.terminate();
|
|
2196
|
-
return;
|
|
2197
|
-
}
|
|
2198
|
-
llmWorkerRef.current = worker;
|
|
2199
|
-
setLoadingMessage(`Loading text-to-speech (${ttsModelId === "supertonic-66m" ? "Supertonic" : "Kokoro"})...`);
|
|
2200
|
-
const { createTTS } = await import("../tts-CqroPaSK.js");
|
|
2201
|
-
if (cancelled || !mountedRef.current) return;
|
|
2202
|
-
const tts = createTTS(ttsModelId);
|
|
2203
|
-
await tts.load({ onProgress: (p) => {
|
|
2204
|
-
if (!mountedRef.current) return;
|
|
2205
|
-
setLoadingMessage(p.status || "Loading TTS...");
|
|
2206
|
-
} });
|
|
2207
|
-
if (cancelled || !mountedRef.current) {
|
|
2208
|
-
await tts.dispose();
|
|
2209
|
-
return;
|
|
2210
|
-
}
|
|
2211
|
-
ttsRef.current = tts;
|
|
2212
|
-
setIsReady(true);
|
|
2213
|
-
setIsLoading(false);
|
|
2214
|
-
setLoadingMessage("Ready!");
|
|
2215
|
-
} catch (e) {
|
|
2216
|
-
if (!mountedRef.current) return;
|
|
2217
|
-
const errMsg = e.message || "Failed to load models";
|
|
2218
|
-
setError(errMsg);
|
|
2219
|
-
setIsLoading(false);
|
|
2220
|
-
onError?.(errMsg);
|
|
2221
|
-
}
|
|
2222
|
-
};
|
|
2223
|
-
loadModels();
|
|
2224
|
-
return () => {
|
|
2225
|
-
cancelled = true;
|
|
450
|
+
function detectMemoryCrash() {
|
|
451
|
+
const lastState = getDownloadPhase();
|
|
452
|
+
const currentSessionId = getSessionId();
|
|
453
|
+
if (!lastState) return { crashed: false };
|
|
454
|
+
const wasInCriticalPhase = [
|
|
455
|
+
"downloading",
|
|
456
|
+
"caching",
|
|
457
|
+
"initializing"
|
|
458
|
+
].includes(lastState.phase);
|
|
459
|
+
const sessionChanged = lastState.sessionId !== currentSessionId;
|
|
460
|
+
const timeSinceCrash = Date.now() - lastState.timestamp;
|
|
461
|
+
if (wasInCriticalPhase && sessionChanged && timeSinceCrash < 300 * 1e3) {
|
|
462
|
+
localStorage.removeItem(SESSION_STORAGE_KEY);
|
|
463
|
+
return {
|
|
464
|
+
crashed: true,
|
|
465
|
+
phase: lastState.phase,
|
|
466
|
+
modelId: lastState.modelId || void 0,
|
|
467
|
+
timeSinceCrash,
|
|
468
|
+
recommendation: lastState.modelId && /2b|gemma|vision/i.test(lastState.modelId) ? "The model was too large for your device. Try Qwen3.5-0.8B instead." : "Your device ran out of memory. Try a smaller model or use a desktop browser."
|
|
2226
469
|
};
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
470
|
+
}
|
|
471
|
+
return { crashed: false };
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Clear session phase (call when model loads successfully).
|
|
475
|
+
*/
|
|
476
|
+
function clearDownloadPhase() {
|
|
477
|
+
if (typeof localStorage === "undefined") return;
|
|
478
|
+
localStorage.removeItem(SESSION_STORAGE_KEY);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/browser/download.ts
|
|
483
|
+
/** Chunk size for downloads: 1.5MB (safe for iOS IndexedDB transactions) */
|
|
484
|
+
const CHUNK_SIZE_BYTES = 1.5 * 1024 * 1024;
|
|
485
|
+
/** IndexedDB database name for chunked downloads */
|
|
486
|
+
const DOWNLOAD_DB_NAME = "gerbil-model-chunks";
|
|
487
|
+
const DOWNLOAD_DB_VERSION = 1;
|
|
488
|
+
/**
|
|
489
|
+
* Open (or create) the IndexedDB for chunked downloads.
|
|
490
|
+
*/
|
|
491
|
+
async function openDownloadDB() {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const request = indexedDB.open(DOWNLOAD_DB_NAME, DOWNLOAD_DB_VERSION);
|
|
494
|
+
request.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to open download DB: ${request.error?.message}`));
|
|
495
|
+
request.onsuccess = () => resolve(request.result);
|
|
496
|
+
request.onupgradeneeded = (event) => {
|
|
497
|
+
const db = event.target.result;
|
|
498
|
+
if (!db.objectStoreNames.contains("manifests")) db.createObjectStore("manifests", { keyPath: "modelId" });
|
|
499
|
+
if (!db.objectStoreNames.contains("chunks")) db.createObjectStore("chunks");
|
|
2244
500
|
};
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Get download manifest for a model.
|
|
505
|
+
*/
|
|
506
|
+
async function getManifest(db, modelId) {
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const request = db.transaction("manifests", "readonly").objectStore("manifests").get(modelId);
|
|
509
|
+
request.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to get manifest: ${request.error?.message}`));
|
|
510
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Save download manifest.
|
|
515
|
+
*/
|
|
516
|
+
async function saveManifest(db, manifest) {
|
|
517
|
+
return new Promise((resolve, reject) => {
|
|
518
|
+
const request = db.transaction("manifests", "readwrite").objectStore("manifests").put(manifest);
|
|
519
|
+
request.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to save manifest: ${request.error?.message}`));
|
|
520
|
+
request.onsuccess = () => resolve();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Save a single chunk.
|
|
525
|
+
*/
|
|
526
|
+
async function saveChunk(db, modelId, chunkIndex, data) {
|
|
527
|
+
return new Promise((resolve, reject) => {
|
|
528
|
+
const store = db.transaction("chunks", "readwrite").objectStore("chunks");
|
|
529
|
+
const key = `${modelId}-${chunkIndex}`;
|
|
530
|
+
const request = store.put(data, key);
|
|
531
|
+
request.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to save chunk ${chunkIndex}: ${request.error?.message}`));
|
|
532
|
+
request.onsuccess = () => resolve();
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Get a single chunk.
|
|
537
|
+
*/
|
|
538
|
+
async function getChunk(db, modelId, chunkIndex) {
|
|
539
|
+
return new Promise((resolve, reject) => {
|
|
540
|
+
const store = db.transaction("chunks", "readonly").objectStore("chunks");
|
|
541
|
+
const key = `${modelId}-${chunkIndex}`;
|
|
542
|
+
const request = store.get(key);
|
|
543
|
+
request.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to get chunk ${chunkIndex}: ${request.error?.message}`));
|
|
544
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Delete all chunks and manifest for a model.
|
|
549
|
+
*/
|
|
550
|
+
async function clearModelData(db, modelId) {
|
|
551
|
+
const manifest = await getManifest(db, modelId);
|
|
552
|
+
return new Promise((resolve, reject) => {
|
|
553
|
+
const tx = db.transaction(["manifests", "chunks"], "readwrite");
|
|
554
|
+
tx.objectStore("manifests").delete(modelId);
|
|
555
|
+
if (manifest) {
|
|
556
|
+
const totalChunks = Math.ceil(manifest.totalBytes / manifest.chunkSize);
|
|
557
|
+
const chunkStore = tx.objectStore("chunks");
|
|
558
|
+
for (let i = 0; i < totalChunks; i++) chunkStore.delete(`${modelId}-${i}`);
|
|
2271
559
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
sourceNodeRef.current = source;
|
|
560
|
+
tx.oncomplete = () => resolve();
|
|
561
|
+
tx.onerror = () => reject(/* @__PURE__ */ new Error(`Failed to clear model data: ${tx.error?.message}`));
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Chunked resumable downloader for large model files.
|
|
566
|
+
* Downloads in 1.5MB chunks to avoid iOS memory pressure.
|
|
567
|
+
*/
|
|
568
|
+
async function downloadModelChunked(url, modelId, options = {}) {
|
|
569
|
+
const { onProgress, signal } = options;
|
|
570
|
+
setDownloadPhase("downloading", modelId);
|
|
571
|
+
const db = await openDownloadDB();
|
|
572
|
+
try {
|
|
573
|
+
let manifest = await getManifest(db, modelId);
|
|
574
|
+
const headResponse = await fetch(url, {
|
|
575
|
+
method: "HEAD",
|
|
576
|
+
signal
|
|
2290
577
|
});
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
578
|
+
if (!headResponse.ok) throw new Error(`HEAD request failed: ${headResponse.status} ${headResponse.statusText}`);
|
|
579
|
+
const contentLength = parseInt(headResponse.headers.get("content-length") || "0", 10);
|
|
580
|
+
const etag = headResponse.headers.get("etag");
|
|
581
|
+
const acceptRanges = headResponse.headers.get("accept-ranges");
|
|
582
|
+
if (!contentLength) throw new Error("Server did not provide content-length");
|
|
583
|
+
if (manifest && manifest.etag !== etag) {
|
|
584
|
+
console.warn(`Model ${modelId} has been updated (etag mismatch). Clearing cached chunks.`);
|
|
585
|
+
await clearModelData(db, modelId);
|
|
586
|
+
manifest = null;
|
|
587
|
+
}
|
|
588
|
+
if (!(acceptRanges === "bytes")) {
|
|
589
|
+
console.warn(`Server doesn't support range requests for ${modelId}. Using regular download.`);
|
|
590
|
+
db.close();
|
|
591
|
+
const response = await fetch(url, { signal });
|
|
592
|
+
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
593
|
+
setDownloadPhase("caching", modelId);
|
|
594
|
+
const buffer = await response.arrayBuffer();
|
|
595
|
+
setDownloadPhase("ready", modelId);
|
|
596
|
+
return buffer;
|
|
597
|
+
}
|
|
598
|
+
const totalChunks = Math.ceil(contentLength / CHUNK_SIZE_BYTES);
|
|
599
|
+
if (!manifest) {
|
|
600
|
+
manifest = {
|
|
601
|
+
modelId,
|
|
602
|
+
url,
|
|
603
|
+
etag,
|
|
604
|
+
totalBytes: contentLength,
|
|
605
|
+
chunkSize: CHUNK_SIZE_BYTES,
|
|
606
|
+
completedChunks: [],
|
|
607
|
+
createdAt: Date.now(),
|
|
608
|
+
updatedAt: Date.now()
|
|
609
|
+
};
|
|
610
|
+
await saveManifest(db, manifest);
|
|
611
|
+
}
|
|
612
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
613
|
+
if (signal?.aborted) throw new Error("Download aborted");
|
|
614
|
+
if (manifest.completedChunks.includes(i)) {
|
|
615
|
+
const bytesDownloaded$1 = manifest.completedChunks.length / totalChunks * contentLength;
|
|
616
|
+
onProgress?.({
|
|
617
|
+
phase: "resuming",
|
|
618
|
+
bytesDownloaded: bytesDownloaded$1,
|
|
619
|
+
totalBytes: contentLength,
|
|
620
|
+
percent: Math.round(bytesDownloaded$1 / contentLength * 100)
|
|
621
|
+
});
|
|
622
|
+
continue;
|
|
2321
623
|
}
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
]),
|
|
2328
|
-
stopListening: useCallback(async () => {
|
|
2329
|
-
if (stage !== "listening") return;
|
|
2330
|
-
const mediaRecorder = mediaRecorderRef.current;
|
|
2331
|
-
if (!mediaRecorder) return;
|
|
2332
|
-
return new Promise((resolve) => {
|
|
2333
|
-
mediaRecorder.onstop = async () => {
|
|
2334
|
-
if (streamRef.current) {
|
|
2335
|
-
for (const track of streamRef.current.getTracks()) track.stop();
|
|
2336
|
-
streamRef.current = null;
|
|
2337
|
-
}
|
|
2338
|
-
if (cancelledRef.current) {
|
|
2339
|
-
setStage("idle");
|
|
2340
|
-
resolve();
|
|
2341
|
-
return;
|
|
2342
|
-
}
|
|
2343
|
-
const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
|
2344
|
-
try {
|
|
2345
|
-
setStage("transcribing");
|
|
2346
|
-
const audioData = await blobToFloat32(audioBlob);
|
|
2347
|
-
let userText = (await sttRef.current.transcribe(audioData)).text.trim();
|
|
2348
|
-
if (userText === "[BLANK_AUDIO]" || userText === "(blank audio)" || userText === "[BLANK AUDIO]") userText = "";
|
|
2349
|
-
if (cancelledRef.current || !userText) {
|
|
2350
|
-
setStage("idle");
|
|
2351
|
-
resolve();
|
|
2352
|
-
return;
|
|
2353
|
-
}
|
|
2354
|
-
const userMsgId = `user-${Date.now()}`;
|
|
2355
|
-
setMessages((m) => [...m, {
|
|
2356
|
-
id: userMsgId,
|
|
2357
|
-
role: "user",
|
|
2358
|
-
content: userText
|
|
2359
|
-
}]);
|
|
2360
|
-
onUserSpeak?.(userText);
|
|
2361
|
-
setStage("thinking");
|
|
2362
|
-
const history = messages.map((m) => ({
|
|
2363
|
-
role: m.role,
|
|
2364
|
-
content: m.content
|
|
2365
|
-
}));
|
|
2366
|
-
history.push({
|
|
2367
|
-
role: "user",
|
|
2368
|
-
content: userText
|
|
2369
|
-
});
|
|
2370
|
-
let responseText = "";
|
|
2371
|
-
let thinkingText = "";
|
|
2372
|
-
await llmWorkerRef.current.generate(userText, {
|
|
2373
|
-
system,
|
|
2374
|
-
thinking,
|
|
2375
|
-
history,
|
|
2376
|
-
onToken: (token) => {
|
|
2377
|
-
if (cancelledRef.current) return;
|
|
2378
|
-
if (token.state === "thinking") thinkingText += token.text;
|
|
2379
|
-
else responseText += token.text;
|
|
2380
|
-
}
|
|
2381
|
-
});
|
|
2382
|
-
if (cancelledRef.current) {
|
|
2383
|
-
setStage("idle");
|
|
2384
|
-
resolve();
|
|
2385
|
-
return;
|
|
2386
|
-
}
|
|
2387
|
-
const assistantMsgId = `assistant-${Date.now()}`;
|
|
2388
|
-
setMessages((m) => [...m, {
|
|
2389
|
-
id: assistantMsgId,
|
|
2390
|
-
role: "assistant",
|
|
2391
|
-
content: responseText,
|
|
2392
|
-
thinking: thinkingText || void 0
|
|
2393
|
-
}]);
|
|
2394
|
-
onAssistantSpeak?.(responseText);
|
|
2395
|
-
if (responseText.trim()) {
|
|
2396
|
-
setStage("speaking");
|
|
2397
|
-
const ttsResult = await ttsRef.current.speak(responseText, {
|
|
2398
|
-
voice,
|
|
2399
|
-
speed
|
|
2400
|
-
});
|
|
2401
|
-
if (!cancelledRef.current) await playAudioBuffer(ttsResult.audio, ttsResult.sampleRate);
|
|
2402
|
-
}
|
|
2403
|
-
setStage("idle");
|
|
2404
|
-
resolve();
|
|
2405
|
-
} catch (e) {
|
|
2406
|
-
if (!mountedRef.current) return;
|
|
2407
|
-
const errMsg = e.message || "Processing failed";
|
|
2408
|
-
setError(errMsg);
|
|
2409
|
-
setStage("idle");
|
|
2410
|
-
onError?.(errMsg);
|
|
2411
|
-
resolve();
|
|
2412
|
-
}
|
|
2413
|
-
};
|
|
2414
|
-
mediaRecorder.stop();
|
|
624
|
+
const start = i * CHUNK_SIZE_BYTES;
|
|
625
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES - 1, contentLength - 1);
|
|
626
|
+
const response = await fetch(url, {
|
|
627
|
+
headers: { Range: `bytes=${start}-${end}` },
|
|
628
|
+
signal
|
|
2415
629
|
});
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
}
|
|
630
|
+
if (response.status !== 206) throw new Error(`Range request failed: ${response.status} (expected 206)`);
|
|
631
|
+
const chunkData = await response.arrayBuffer();
|
|
632
|
+
await saveChunk(db, modelId, i, chunkData);
|
|
633
|
+
manifest.completedChunks.push(i);
|
|
634
|
+
manifest.updatedAt = Date.now();
|
|
635
|
+
await saveManifest(db, manifest);
|
|
636
|
+
const bytesDownloaded = manifest.completedChunks.length * CHUNK_SIZE_BYTES;
|
|
637
|
+
setDownloadPhase("downloading", modelId, {
|
|
638
|
+
bytesDownloaded,
|
|
639
|
+
totalBytes: contentLength
|
|
640
|
+
});
|
|
641
|
+
onProgress?.({
|
|
642
|
+
phase: "downloading",
|
|
643
|
+
bytesDownloaded: Math.min(bytesDownloaded, contentLength),
|
|
644
|
+
totalBytes: contentLength,
|
|
645
|
+
percent: Math.round(manifest.completedChunks.length / totalChunks * 100)
|
|
646
|
+
});
|
|
647
|
+
response.body = null;
|
|
648
|
+
}
|
|
649
|
+
setDownloadPhase("caching", modelId);
|
|
650
|
+
onProgress?.({
|
|
651
|
+
phase: "assembling",
|
|
652
|
+
bytesDownloaded: contentLength,
|
|
653
|
+
totalBytes: contentLength,
|
|
654
|
+
percent: 100
|
|
655
|
+
});
|
|
656
|
+
const finalBuffer = new ArrayBuffer(contentLength);
|
|
657
|
+
const finalView = new Uint8Array(finalBuffer);
|
|
658
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
659
|
+
const chunk = await getChunk(db, modelId, i);
|
|
660
|
+
if (!chunk) throw new Error(`Missing chunk ${i} during assembly`);
|
|
661
|
+
const offset = i * CHUNK_SIZE_BYTES;
|
|
662
|
+
finalView.set(new Uint8Array(chunk), offset);
|
|
663
|
+
}
|
|
664
|
+
await clearModelData(db, modelId);
|
|
665
|
+
db.close();
|
|
666
|
+
setDownloadPhase("ready", modelId);
|
|
667
|
+
return finalBuffer;
|
|
668
|
+
} catch (error) {
|
|
669
|
+
setDownloadPhase("error", modelId);
|
|
670
|
+
db.close();
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
2455
673
|
}
|
|
2456
674
|
/**
|
|
2457
|
-
* Check if
|
|
675
|
+
* Check if a model has an incomplete download.
|
|
2458
676
|
*/
|
|
2459
|
-
function
|
|
2460
|
-
|
|
2461
|
-
|
|
677
|
+
async function hasIncompleteDownload(modelId) {
|
|
678
|
+
try {
|
|
679
|
+
const db = await openDownloadDB();
|
|
680
|
+
const manifest = await getManifest(db, modelId);
|
|
681
|
+
db.close();
|
|
682
|
+
if (!manifest) return { incomplete: false };
|
|
683
|
+
const totalChunks = Math.ceil(manifest.totalBytes / manifest.chunkSize);
|
|
684
|
+
const completedChunks = manifest.completedChunks.length;
|
|
685
|
+
if (completedChunks < totalChunks) return {
|
|
686
|
+
incomplete: true,
|
|
687
|
+
bytesDownloaded: completedChunks * manifest.chunkSize,
|
|
688
|
+
totalBytes: manifest.totalBytes,
|
|
689
|
+
percent: Math.round(completedChunks / totalChunks * 100)
|
|
690
|
+
};
|
|
691
|
+
return { incomplete: false };
|
|
692
|
+
} catch {
|
|
693
|
+
return { incomplete: false };
|
|
694
|
+
}
|
|
2462
695
|
}
|
|
2463
696
|
/**
|
|
2464
|
-
*
|
|
697
|
+
* Clear incomplete download data for a model.
|
|
2465
698
|
*/
|
|
2466
|
-
async function
|
|
2467
|
-
if (!isWebGPUSupported()) return { supported: false };
|
|
699
|
+
async function clearIncompleteDownload(modelId) {
|
|
2468
700
|
try {
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
|
|
701
|
+
const db = await openDownloadDB();
|
|
702
|
+
await clearModelData(db, modelId);
|
|
703
|
+
db.close();
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Check if there's enough storage quota for a model download.
|
|
708
|
+
* Returns estimated available space and whether download should proceed.
|
|
709
|
+
*/
|
|
710
|
+
async function checkStorageQuota(requiredMB = 500) {
|
|
711
|
+
if (typeof navigator === "undefined" || !navigator.storage?.estimate) return {
|
|
712
|
+
ok: true,
|
|
713
|
+
availableMB: -1,
|
|
714
|
+
usedMB: -1,
|
|
715
|
+
quotaMB: -1,
|
|
716
|
+
message: "Storage API not available"
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const { quota, usage } = await navigator.storage.estimate();
|
|
720
|
+
const quotaMB = Math.round((quota || 0) / 1e6);
|
|
721
|
+
const usedMB = Math.round((usage || 0) / 1e6);
|
|
722
|
+
const availableMB = quotaMB - usedMB;
|
|
723
|
+
if (availableMB < requiredMB) return {
|
|
724
|
+
ok: false,
|
|
725
|
+
availableMB,
|
|
726
|
+
usedMB,
|
|
727
|
+
quotaMB,
|
|
728
|
+
message: `Need ${requiredMB}MB but only ${availableMB}MB available. Clear browser data or free up space.`
|
|
729
|
+
};
|
|
2472
730
|
return {
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
731
|
+
ok: true,
|
|
732
|
+
availableMB,
|
|
733
|
+
usedMB,
|
|
734
|
+
quotaMB
|
|
735
|
+
};
|
|
736
|
+
} catch (e) {
|
|
737
|
+
return {
|
|
738
|
+
ok: true,
|
|
739
|
+
availableMB: -1,
|
|
740
|
+
usedMB: -1,
|
|
741
|
+
quotaMB: -1,
|
|
742
|
+
message: `Storage check failed: ${e.message}`
|
|
2476
743
|
};
|
|
2477
|
-
} catch {
|
|
2478
|
-
return { supported: false };
|
|
2479
744
|
}
|
|
2480
745
|
}
|
|
2481
|
-
var browser_default = {
|
|
2482
|
-
isWebGPUSupported,
|
|
2483
|
-
getWebGPUInfo,
|
|
2484
|
-
createGerbilWorker,
|
|
2485
|
-
playAudio,
|
|
2486
|
-
createAudioPlayer
|
|
2487
|
-
};
|
|
2488
746
|
|
|
2489
747
|
//#endregion
|
|
2490
|
-
export { BUILTIN_MODELS, createAudioPlayer,
|
|
748
|
+
export { BUILTIN_MODELS, CHUNK_SIZE_BYTES, DOWNLOAD_DB_NAME, MODEL_SIZES, SESSION_STORAGE_KEY, canCacheModel, checkStorageQuota, clearDownloadPhase, clearIncompleteDownload, createAudioPlayer, detectMemoryCrash, downloadModelChunked, getDownloadPhase, getInstallGuidance, getRecommendedModels, getStorageStatus, hasIncompleteDownload, isIOS, isModelSafeForDevice, isStandalone, playAudio, requestPersistentStorage, setDownloadPhase };
|
|
2491
749
|
//# sourceMappingURL=index.js.map
|