@iicp/web-node 0.1.0
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 +180 -0
- package/README.md +68 -0
- package/dist/browserNodeProvider.d.ts +73 -0
- package/dist/browserNodeProvider.js +455 -0
- package/dist/cxConfidentiality.d.ts +27 -0
- package/dist/cxConfidentiality.js +117 -0
- package/dist/iicpConsumer.d.ts +104 -0
- package/dist/iicpConsumer.js +201 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/webllmRuntime.d.ts +140 -0
- package/dist/webllmRuntime.js +259 -0
- package/package.json +60 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// WebLLM in-browser model runtime — issue #451 (WASM-4).
|
|
4
|
+
// Integrates @mlc-ai/web-llm 0.2.84 (WebGPU + IndexedDB model cache) for opt-in
|
|
5
|
+
// local inference. Provider glue (#452) calls this to serve llm:chat tasks from
|
|
6
|
+
// a browser node; the demo page (#449) uses it for the "no install required" path.
|
|
7
|
+
//
|
|
8
|
+
// Dep: @mlc-ai/web-llm 0.2.84 (Apache-2.0, TC-11 cleared 2026-06-12,
|
|
9
|
+
// sha512=hrOWzK4/nGNmgoRKT8pgVmZZ2oEPpbblIWQOwpqNyvK2dysHw3KVB1gNJOuRcQfKOPhucEhX1NJzXzgMDnwSCQ==)
|
|
10
|
+
//
|
|
11
|
+
// Dynamic import pattern: @mlc-ai/web-llm is NOT statically imported here,
|
|
12
|
+
// which keeps it out of the initial Next.js bundle. The load() call triggers
|
|
13
|
+
// the code-split chunk only when the user opts in.
|
|
14
|
+
/**
|
|
15
|
+
* Supported model IDs for UI display. Lineup (maintainer 2026-06-12): the two
|
|
16
|
+
* lightest variants run fine on ANY computer ("CPU" tier — integrated
|
|
17
|
+
* graphics, no dedicated card needed), plus one quality model that wants a
|
|
18
|
+
* dedicated GPU. tier drives the CPU/GPU badge on the picker.
|
|
19
|
+
* (Technically WebLLM always executes via WebGPU; the tier expresses the
|
|
20
|
+
* hardware a model needs to run WELL — tooltips carry that nuance.)
|
|
21
|
+
*/
|
|
22
|
+
export const WEBLLM_MODELS = {
|
|
23
|
+
"Qwen2.5-0.5B-Instruct-q4f32_1-MLC": {
|
|
24
|
+
label: "Qwen 2.5 0.5B",
|
|
25
|
+
sizeMB: 350,
|
|
26
|
+
tier: "cpu",
|
|
27
|
+
description: "Lightest — runs on any computer, no graphics card needed",
|
|
28
|
+
},
|
|
29
|
+
"Llama-3.2-1B-Instruct-q4f32_1-MLC": {
|
|
30
|
+
label: "Llama 3.2 1B",
|
|
31
|
+
sizeMB: 500,
|
|
32
|
+
tier: "cpu",
|
|
33
|
+
description: "Light and capable — fine on laptops with integrated graphics",
|
|
34
|
+
},
|
|
35
|
+
"Llama-3.2-3B-Instruct-q4f32_1-MLC": {
|
|
36
|
+
label: "Llama 3.2 3B",
|
|
37
|
+
sizeMB: 1500,
|
|
38
|
+
tier: "gpu",
|
|
39
|
+
description: "Best quality — needs a dedicated graphics card (~4 GB VRAM)",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export const DEFAULT_MODEL = "Qwen2.5-0.5B-Instruct-q4f32_1-MLC";
|
|
43
|
+
export function assessDevice() {
|
|
44
|
+
if (typeof navigator === "undefined") {
|
|
45
|
+
return { deviceClass: "ok", deviceMemoryGB: null, gpuModelRisky: false, note: "" };
|
|
46
|
+
}
|
|
47
|
+
const mem = typeof navigator.deviceMemory === "number"
|
|
48
|
+
? navigator.deviceMemory ?? null
|
|
49
|
+
: null;
|
|
50
|
+
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
|
|
51
|
+
if (isMobile) {
|
|
52
|
+
return {
|
|
53
|
+
deviceClass: "mobile",
|
|
54
|
+
deviceMemoryGB: mem,
|
|
55
|
+
gpuModelRisky: true,
|
|
56
|
+
note: "On phones and tablets, in-browser models often run out of memory. Start with the lightest model — or skip it and use “Ask the mesh” below, which runs on a remote node (no WebGPU needed).",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (mem !== null && mem <= 4) {
|
|
60
|
+
return {
|
|
61
|
+
deviceClass: "low",
|
|
62
|
+
deviceMemoryGB: mem,
|
|
63
|
+
gpuModelRisky: true,
|
|
64
|
+
note: `Your device reports ~${mem} GB of memory — the lightest model is recommended; larger ones may fail to load.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { deviceClass: "ok", deviceMemoryGB: mem, gpuModelRisky: false, note: "" };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Detect WebGPU availability. Safe to call server-side (returns unsupported).
|
|
71
|
+
* Chrome 113+, Firefox nightly, Safari Technology Preview.
|
|
72
|
+
*/
|
|
73
|
+
export function detectWebGPU() {
|
|
74
|
+
if (typeof navigator === "undefined") {
|
|
75
|
+
return { supported: false, reason: "server-side render" };
|
|
76
|
+
}
|
|
77
|
+
if (!("gpu" in navigator)) {
|
|
78
|
+
return {
|
|
79
|
+
supported: false,
|
|
80
|
+
reason: "WebGPU not available — requires Chrome 113+, Firefox nightly, or Safari TP",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { supported: true };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Async WebGPU probe — the REAL availability signal. `detectWebGPU()` only
|
|
87
|
+
* checks that `navigator.gpu` exists, which some browsers expose while still
|
|
88
|
+
* being unable to run a model (e.g. Firefox surfaces `navigator.gpu` but has no
|
|
89
|
+
* usable adapter unless `dom.webgpu.enabled` is set, producing a false-positive
|
|
90
|
+
* "WebGPU available" badge — #518). This calls `requestAdapter()` to confirm a
|
|
91
|
+
* usable GPU, so UI that gates on it reflects reality, not just API presence.
|
|
92
|
+
*/
|
|
93
|
+
export async function probeWebGPU() {
|
|
94
|
+
const presence = detectWebGPU();
|
|
95
|
+
if (!presence.supported)
|
|
96
|
+
return presence;
|
|
97
|
+
try {
|
|
98
|
+
const nav = navigator;
|
|
99
|
+
const adapter = await nav.gpu?.requestAdapter();
|
|
100
|
+
if (!adapter) {
|
|
101
|
+
return {
|
|
102
|
+
supported: false,
|
|
103
|
+
reason: "your browser exposes WebGPU but no usable GPU adapter was found — it may be blocklisted, hardware acceleration may be off, or WebGPU isn't enabled on this device",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { supported: true };
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return {
|
|
110
|
+
supported: false,
|
|
111
|
+
reason: `WebGPU adapter probe failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── Error ─────────────────────────────────────────────────────────────────────
|
|
116
|
+
/**
|
|
117
|
+
* Classify a load-failure message as out-of-memory (#517 D4). OOM is the most
|
|
118
|
+
* common real-device failure (phones / integrated GPUs); detecting it lets the
|
|
119
|
+
* UI steer to a smaller model or the mesh instead of a generic error. Matches
|
|
120
|
+
* the strings WebGPU/WebLLM/browsers surface for memory exhaustion.
|
|
121
|
+
*/
|
|
122
|
+
export function isOutOfMemoryError(message) {
|
|
123
|
+
return /out of memory|out-of-memory|\boom\b|device lost|devicelost|allocat|exceeds the limit|insufficient|RangeError/i.test(message);
|
|
124
|
+
}
|
|
125
|
+
export class WebLLMError extends Error {
|
|
126
|
+
code;
|
|
127
|
+
constructor(message, code) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.code = code;
|
|
130
|
+
this.name = "WebLLMError";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* In-browser LLM runtime wrapping @mlc-ai/web-llm.
|
|
135
|
+
*
|
|
136
|
+
* Lifecycle: construct → canRun() check → load() → chat() → unload()
|
|
137
|
+
*
|
|
138
|
+
* Model weights are downloaded once and cached in IndexedDB by the WebLLM
|
|
139
|
+
* engine (automatic, per-origin). Subsequent loads skip the download.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* const rt = new WebLLMRuntime();
|
|
144
|
+
* if (!rt.canRun()) return; // WebGPU unavailable
|
|
145
|
+
* await rt.load('Llama-3.2-1B-Instruct-q4f32_1-MLC', p => setProgress(p));
|
|
146
|
+
* const reply = await rt.chat([{ role: 'user', content: 'Hello' }]);
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export class WebLLMRuntime {
|
|
150
|
+
_engine = null;
|
|
151
|
+
_modelId = null;
|
|
152
|
+
/** True if WebGPU is available in this browser/environment. */
|
|
153
|
+
canRun() {
|
|
154
|
+
return detectWebGPU().supported;
|
|
155
|
+
}
|
|
156
|
+
/** True if a model is currently loaded and ready for inference. */
|
|
157
|
+
isLoaded() {
|
|
158
|
+
return this._engine !== null;
|
|
159
|
+
}
|
|
160
|
+
/** Currently loaded model ID, or null if not loaded. */
|
|
161
|
+
get modelId() {
|
|
162
|
+
return this._modelId;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Load a model. First call downloads weights (~300–1500 MB) and caches them
|
|
166
|
+
* in IndexedDB. Subsequent calls with the same model skip the download.
|
|
167
|
+
*
|
|
168
|
+
* @param model Model ID from WEBLLM_MODELS (default: Llama 3.2 1B q4)
|
|
169
|
+
* @param onProgress Optional progress callback (text, 0–1 fraction)
|
|
170
|
+
* @throws WebLLMError If WebGPU is unavailable or model load fails
|
|
171
|
+
*/
|
|
172
|
+
async load(model = DEFAULT_MODEL, onProgress) {
|
|
173
|
+
const gpuCheck = detectWebGPU();
|
|
174
|
+
if (!gpuCheck.supported) {
|
|
175
|
+
throw new WebLLMError(`WebGPU unavailable: ${gpuCheck.reason}`, "webgpu_unavailable");
|
|
176
|
+
}
|
|
177
|
+
// requestAdapter() is the real hardware test — navigator.gpu existing is not enough.
|
|
178
|
+
// Returns null when GPU is on the blocklist, hardware-accel is disabled, or no adapter.
|
|
179
|
+
const nav = navigator;
|
|
180
|
+
const adapter = await nav.gpu?.requestAdapter();
|
|
181
|
+
if (!adapter) {
|
|
182
|
+
throw new WebLLMError("WebGPU adapter unavailable — GPU may be blocklisted, hardware acceleration may be disabled, or WebGPU isn't supported on this device/OS", "webgpu_unavailable");
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
// Dynamic import — creates a separate bundle chunk, loaded only on user opt-in.
|
|
186
|
+
const webllm = await import("@mlc-ai/web-llm");
|
|
187
|
+
const engine = (await webllm.CreateMLCEngine(model, {
|
|
188
|
+
initProgressCallback: (report) => {
|
|
189
|
+
onProgress?.({ text: report.text, progress: report.progress });
|
|
190
|
+
},
|
|
191
|
+
}));
|
|
192
|
+
this._engine = engine;
|
|
193
|
+
this._modelId = model;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// Log full detail so users can copy from F12 console
|
|
197
|
+
console.error("[WebLLMRuntime] load() failed:", err);
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
const cause = err instanceof Error
|
|
200
|
+
? err.cause
|
|
201
|
+
: undefined;
|
|
202
|
+
const causeMsg = cause instanceof Error
|
|
203
|
+
? `: ${cause.message}`
|
|
204
|
+
: cause
|
|
205
|
+
? `: ${String(cause)}`
|
|
206
|
+
: "";
|
|
207
|
+
// Out-of-memory is the most common real-device load failure (esp. on
|
|
208
|
+
// phones / integrated GPUs). Detect it so the UI can steer to a smaller
|
|
209
|
+
// model or the mesh rather than showing a generic "load failed". #517 D4.
|
|
210
|
+
const full = `${msg}${causeMsg}`;
|
|
211
|
+
if (isOutOfMemoryError(full)) {
|
|
212
|
+
throw new WebLLMError(`Ran out of memory loading this model: ${full}`, "out_of_memory");
|
|
213
|
+
}
|
|
214
|
+
throw new WebLLMError(`Model load failed: ${full}`, "load_failed");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* CIP-compatible chat inference. Returns the assistant reply text.
|
|
219
|
+
* Provider glue (#452) calls this to satisfy llm:chat intent tasks.
|
|
220
|
+
* The model is fixed at load() time — pass a different modelId to load() to switch.
|
|
221
|
+
*
|
|
222
|
+
* @param messages Chat history (system/user/assistant turns)
|
|
223
|
+
* @throws WebLLMError If no model is loaded or inference fails
|
|
224
|
+
*/
|
|
225
|
+
async chat(messages, opts) {
|
|
226
|
+
if (!this._engine) {
|
|
227
|
+
throw new WebLLMError("No model loaded — call load() first", "not_loaded");
|
|
228
|
+
}
|
|
229
|
+
let resp;
|
|
230
|
+
try {
|
|
231
|
+
resp = await this._engine.chat.completions.create({
|
|
232
|
+
messages,
|
|
233
|
+
stream: false,
|
|
234
|
+
...(opts?.temperature !== undefined && { temperature: opts.temperature }),
|
|
235
|
+
...(opts?.max_tokens !== undefined && { max_tokens: opts.max_tokens }),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
240
|
+
throw new WebLLMError(`Inference failed: ${msg}`, "inference_failed");
|
|
241
|
+
}
|
|
242
|
+
const content = resp.choices?.[0]?.message?.content;
|
|
243
|
+
if (typeof content !== "string") {
|
|
244
|
+
throw new WebLLMError("Unexpected response shape from WebLLM engine", "bad_response");
|
|
245
|
+
}
|
|
246
|
+
return content;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Unload the model and release GPU memory.
|
|
250
|
+
* Safe to call when not loaded (no-op).
|
|
251
|
+
*/
|
|
252
|
+
async unload() {
|
|
253
|
+
if (this._engine) {
|
|
254
|
+
await this._engine.unload();
|
|
255
|
+
this._engine = null;
|
|
256
|
+
this._modelId = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iicp/web-node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser-native IICP node (consume + serve): discovery-mesh client with mandatory E2E encryption (IICP-CX) + WebLLM provider. Zero-config, ESM.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
24
|
+
"test": "tsx --test tests/*.test.ts"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"iicp",
|
|
28
|
+
"browser",
|
|
29
|
+
"node",
|
|
30
|
+
"consumer",
|
|
31
|
+
"provider",
|
|
32
|
+
"discovery",
|
|
33
|
+
"mesh",
|
|
34
|
+
"webllm",
|
|
35
|
+
"e2e",
|
|
36
|
+
"encryption",
|
|
37
|
+
"esm"
|
|
38
|
+
],
|
|
39
|
+
"license": "Apache-2.0",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@mlc-ai/web-llm": "^0.2.84",
|
|
45
|
+
"tsx": "^4.19.2",
|
|
46
|
+
"typescript": "^5.6.3"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@noble/curves": "^2.2.0",
|
|
50
|
+
"@noble/hashes": "^2.2.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@mlc-ai/web-llm": ">=0.2.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@mlc-ai/web-llm": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|