@missionsquad/browserai 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.
Files changed (146) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +229 -0
  3. package/dist/audio/clip.d.ts +51 -0
  4. package/dist/audio/clip.d.ts.map +1 -0
  5. package/dist/audio/clip.js +71 -0
  6. package/dist/audio/clip.js.map +1 -0
  7. package/dist/audio/decode.d.ts +7 -0
  8. package/dist/audio/decode.d.ts.map +1 -0
  9. package/dist/audio/decode.js +19 -0
  10. package/dist/audio/decode.js.map +1 -0
  11. package/dist/audio/pcm.d.ts +13 -0
  12. package/dist/audio/pcm.d.ts.map +1 -0
  13. package/dist/audio/pcm.js +66 -0
  14. package/dist/audio/pcm.js.map +1 -0
  15. package/dist/audio/recorder.d.ts +40 -0
  16. package/dist/audio/recorder.d.ts.map +1 -0
  17. package/dist/audio/recorder.js +127 -0
  18. package/dist/audio/recorder.js.map +1 -0
  19. package/dist/audio/streaming-player.d.ts +29 -0
  20. package/dist/audio/streaming-player.d.ts.map +1 -0
  21. package/dist/audio/streaming-player.js +82 -0
  22. package/dist/audio/streaming-player.js.map +1 -0
  23. package/dist/client.d.ts +195 -0
  24. package/dist/client.d.ts.map +1 -0
  25. package/dist/client.js +638 -0
  26. package/dist/client.js.map +1 -0
  27. package/dist/config.d.ts +81 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +19 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/errors.d.ts +52 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +74 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/events.d.ts +18 -0
  36. package/dist/events.d.ts.map +1 -0
  37. package/dist/events.js +43 -0
  38. package/dist/events.js.map +1 -0
  39. package/dist/generation.d.ts +12 -0
  40. package/dist/generation.d.ts.map +1 -0
  41. package/dist/generation.js +30 -0
  42. package/dist/generation.js.map +1 -0
  43. package/dist/hardware.d.ts +23 -0
  44. package/dist/hardware.d.ts.map +1 -0
  45. package/dist/hardware.js +150 -0
  46. package/dist/hardware.js.map +1 -0
  47. package/dist/history.d.ts +81 -0
  48. package/dist/history.d.ts.map +1 -0
  49. package/dist/history.js +193 -0
  50. package/dist/history.js.map +1 -0
  51. package/dist/index.d.ts +51 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +50 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/inference.d.ts +89 -0
  56. package/dist/inference.d.ts.map +1 -0
  57. package/dist/inference.js +411 -0
  58. package/dist/inference.js.map +1 -0
  59. package/dist/json.d.ts +22 -0
  60. package/dist/json.d.ts.map +1 -0
  61. package/dist/json.js +145 -0
  62. package/dist/json.js.map +1 -0
  63. package/dist/metrics.d.ts +18 -0
  64. package/dist/metrics.d.ts.map +1 -0
  65. package/dist/metrics.js +59 -0
  66. package/dist/metrics.js.map +1 -0
  67. package/dist/model-cache.d.ts +49 -0
  68. package/dist/model-cache.d.ts.map +1 -0
  69. package/dist/model-cache.js +511 -0
  70. package/dist/model-cache.js.map +1 -0
  71. package/dist/model-source.d.ts +27 -0
  72. package/dist/model-source.d.ts.map +1 -0
  73. package/dist/model-source.js +110 -0
  74. package/dist/model-source.js.map +1 -0
  75. package/dist/models.d.ts +103 -0
  76. package/dist/models.d.ts.map +1 -0
  77. package/dist/models.js +1052 -0
  78. package/dist/models.js.map +1 -0
  79. package/dist/multimodal.d.ts +261 -0
  80. package/dist/multimodal.d.ts.map +1 -0
  81. package/dist/multimodal.js +506 -0
  82. package/dist/multimodal.js.map +1 -0
  83. package/dist/prompt.d.ts +13 -0
  84. package/dist/prompt.d.ts.map +1 -0
  85. package/dist/prompt.js +32 -0
  86. package/dist/prompt.js.map +1 -0
  87. package/dist/proxy-verify.d.ts +10 -0
  88. package/dist/proxy-verify.d.ts.map +1 -0
  89. package/dist/proxy-verify.js +98 -0
  90. package/dist/proxy-verify.js.map +1 -0
  91. package/dist/schema-validator.d.ts +6 -0
  92. package/dist/schema-validator.d.ts.map +1 -0
  93. package/dist/schema-validator.js +104 -0
  94. package/dist/schema-validator.js.map +1 -0
  95. package/dist/schema.d.ts +98 -0
  96. package/dist/schema.d.ts.map +1 -0
  97. package/dist/schema.js +115 -0
  98. package/dist/schema.js.map +1 -0
  99. package/dist/text-utils.d.ts +8 -0
  100. package/dist/text-utils.d.ts.map +1 -0
  101. package/dist/text-utils.js +14 -0
  102. package/dist/text-utils.js.map +1 -0
  103. package/dist/voice/helpers.d.ts +35 -0
  104. package/dist/voice/helpers.d.ts.map +1 -0
  105. package/dist/voice/helpers.js +49 -0
  106. package/dist/voice/helpers.js.map +1 -0
  107. package/dist/voice/voice-pipeline.d.ts +155 -0
  108. package/dist/voice/voice-pipeline.d.ts.map +1 -0
  109. package/dist/voice/voice-pipeline.js +407 -0
  110. package/dist/voice/voice-pipeline.js.map +1 -0
  111. package/dist/webllm.worker.d.ts +2 -0
  112. package/dist/webllm.worker.d.ts.map +1 -0
  113. package/dist/webllm.worker.js +8 -0
  114. package/dist/webllm.worker.js.map +1 -0
  115. package/package.json +62 -0
  116. package/src/audio/clip.ts +94 -0
  117. package/src/audio/decode.ts +19 -0
  118. package/src/audio/pcm.ts +62 -0
  119. package/src/audio/recorder.ts +146 -0
  120. package/src/audio/streaming-player.ts +99 -0
  121. package/src/client.ts +784 -0
  122. package/src/config.ts +104 -0
  123. package/src/errors.ts +81 -0
  124. package/src/events.ts +53 -0
  125. package/src/generation.ts +39 -0
  126. package/src/hardware.ts +193 -0
  127. package/src/history.ts +257 -0
  128. package/src/index.ts +203 -0
  129. package/src/inference.ts +579 -0
  130. package/src/json.ts +169 -0
  131. package/src/metrics.ts +63 -0
  132. package/src/model-cache.ts +637 -0
  133. package/src/model-source.ts +142 -0
  134. package/src/models.ts +1171 -0
  135. package/src/multimodal.ts +697 -0
  136. package/src/prompt.ts +33 -0
  137. package/src/proxy-verify.ts +113 -0
  138. package/src/schema-validator.ts +117 -0
  139. package/src/schema.ts +118 -0
  140. package/src/text-utils.ts +12 -0
  141. package/src/voice/helpers.ts +59 -0
  142. package/src/voice/voice-pipeline.ts +513 -0
  143. package/src/webllm.worker.ts +9 -0
  144. package/worker-template/README.md +39 -0
  145. package/worker-template/cloudflare-worker.ts +389 -0
  146. package/worker-template/wrangler.jsonc +19 -0
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { BrowserCacheBackend } from "./model-source.js";
2
+ import type { KokoroModule } from "./multimodal.js";
3
+
4
+ /** Default WebLLM context-window baseline; a differing override becomes `context_window_size`. */
5
+ export const DEFAULT_CONTEXT_LENGTH = 4096;
6
+
7
+ /** Storage adapter for run-history persistence (matches the `localStorage` surface the SDK uses). */
8
+ export type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
9
+
10
+ export type WebLLMOptions = {
11
+ /**
12
+ * Factory for a dedicated module worker running the SDK's WebLLM handler (the
13
+ * `@missionsquad/browserai/worker` entry). When provided, engines are created with
14
+ * `CreateWebWorkerMLCEngine` so loading + token generation stay off the main thread; when absent,
15
+ * the SDK uses the main-thread `CreateMLCEngine`.
16
+ */
17
+ worker?: () => Worker;
18
+ /** WebLLM engine log level. Default "INFO". */
19
+ logLevel?: "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "SILENT";
20
+ };
21
+
22
+ export type KokoroOptions = {
23
+ /** Override the kokoro-js ESM bundle URL (must export KokoroTTS + TextSplitterStream). */
24
+ url?: string;
25
+ /** Fully replace module loading (e.g. a bundled import). Wins over `url`. */
26
+ load?: () => Promise<KokoroModule>;
27
+ };
28
+
29
+ export type TtsDefaults = {
30
+ /** Voice id for the loaded TTS model's roster (Kokoro: "af_heart" etc.; Supertonic: "F1"–"M5"). */
31
+ voice: string;
32
+ /** Speech speed multiplier. Kokoro bakes it into synthesis; Supertonic applies it at playback. */
33
+ speed: number;
34
+ /** Pitch ratio baked into produced clips (1 = unchanged, 2 = up an octave). */
35
+ pitch: number;
36
+ };
37
+
38
+ export type HistoryOptions = {
39
+ /**
40
+ * Persistence for the run log. Defaults to `globalThis.localStorage` when available; pass `null`
41
+ * to keep history in memory only.
42
+ */
43
+ storage?: StorageLike | null;
44
+ /** localStorage key. */
45
+ key?: string;
46
+ /** Cap on persisted records (newest first). */
47
+ maxRecords?: number;
48
+ };
49
+
50
+ export type BrowserAIConfig = {
51
+ /**
52
+ * Where model artifacts are fetched from. "direct" (default) downloads straight from Hugging Face
53
+ * and raw GitHub — works on any site. "proxy" routes through a same-origin (or configured-origin)
54
+ * deployment of the bundled Cloudflare Worker template (`/hf/*`, `/hf-transformers/*`, `/gh-raw/*`).
55
+ */
56
+ modelSource?: "direct" | "proxy";
57
+ /**
58
+ * Origin of the proxy Worker when `modelSource: "proxy"`. Defaults to the page's own origin.
59
+ * Ignored in direct mode.
60
+ */
61
+ proxyOrigin?: string;
62
+ /** Probe the proxy (health + config HEAD checks) before each proxied load. Default true. */
63
+ verifyProxy?: boolean;
64
+ /**
65
+ * WebLLM artifact cache backend. Default "indexeddb" (clearer failures than the Cache API on
66
+ * proxied deployments). Transformers.js artifacts always use the browser Cache API bucket
67
+ * "transformers-cache" regardless of this setting.
68
+ */
69
+ cacheBackend?: BrowserCacheBackend;
70
+ webllm?: WebLLMOptions;
71
+ kokoro?: KokoroOptions;
72
+ /** Default TTS voice/speed/pitch used by synthesize helpers and the voice pipeline. */
73
+ tts?: Partial<TtsDefaults>;
74
+ history?: HistoryOptions;
75
+ };
76
+
77
+ export type ResolvedConfig = {
78
+ modelSource: "direct" | "proxy";
79
+ proxyOrigin: string | null;
80
+ verifyProxy: boolean;
81
+ cacheBackend: BrowserCacheBackend;
82
+ webllm: WebLLMOptions;
83
+ kokoro: KokoroOptions;
84
+ tts: TtsDefaults;
85
+ history: HistoryOptions;
86
+ };
87
+
88
+ /** Apply defaults. `proxyOrigin` falls back to the page origin only when running in a window/worker. */
89
+ export function resolveConfig(config: BrowserAIConfig = {}): ResolvedConfig {
90
+ const pageOrigin =
91
+ typeof globalThis.location !== "undefined" && typeof globalThis.location.origin === "string"
92
+ ? globalThis.location.origin
93
+ : null;
94
+ return {
95
+ modelSource: config.modelSource ?? "direct",
96
+ proxyOrigin: config.proxyOrigin ?? pageOrigin,
97
+ verifyProxy: config.verifyProxy ?? true,
98
+ cacheBackend: config.cacheBackend ?? "indexeddb",
99
+ webllm: config.webllm ?? {},
100
+ kokoro: config.kokoro ?? {},
101
+ tts: { voice: "af_heart", speed: 1, pitch: 1, ...config.tts },
102
+ history: config.history ?? {},
103
+ };
104
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Custom error classes for the SDK. Every error thrown by BrowserAI code is (or wraps into) a
3
+ * `BrowserAIError` subclass so hosts can branch with `instanceof` instead of parsing messages.
4
+ */
5
+
6
+ /** Format an unknown thrown value as a human-readable message. */
7
+ export function formatError(error: unknown): string {
8
+ return error instanceof Error ? error.message : String(error);
9
+ }
10
+
11
+ /** Base class for all SDK errors. */
12
+ export class BrowserAIError extends Error {
13
+ constructor(message: string, options?: { cause?: unknown }) {
14
+ super(message, options);
15
+ this.name = new.target.name;
16
+ }
17
+ }
18
+
19
+ /** A model id that is not present in the catalog. */
20
+ export class UnknownModelError extends BrowserAIError {
21
+ readonly modelId: string;
22
+ constructor(modelId: string) {
23
+ super(`Unknown model id: ${modelId}`);
24
+ this.modelId = modelId;
25
+ }
26
+ }
27
+
28
+ /** WebGPU is required for the requested load but is not available. */
29
+ export class WebGPUUnavailableError extends BrowserAIError {
30
+ /** The probe's explanation (e.g. "navigator.gpu is not available."). */
31
+ readonly reason: string;
32
+ constructor(reason: string) {
33
+ super(`WebGPU unavailable: ${reason}`);
34
+ this.reason = reason;
35
+ }
36
+ }
37
+
38
+ /** A model load failed. Wraps the underlying engine/network error as `cause`. */
39
+ export class ModelLoadError extends BrowserAIError {
40
+ readonly modelId: string;
41
+ constructor(modelId: string, cause: unknown) {
42
+ super(`Model load failed for ${modelId}: ${formatError(cause)}`, { cause });
43
+ this.modelId = modelId;
44
+ }
45
+ }
46
+
47
+ /** The same-origin proxy did not pass its pre-load health/probe checks. */
48
+ export class ProxyVerificationError extends BrowserAIError {}
49
+
50
+ /** An operation needs a capability slot (text/vision/stt/tts) that no loaded model fills. */
51
+ export class MissingModelError extends BrowserAIError {
52
+ readonly slot: string;
53
+ constructor(slot: string, message?: string) {
54
+ super(message ?? `No loaded model fills the "${slot}" capability slot.`);
55
+ this.slot = slot;
56
+ }
57
+ }
58
+
59
+ /** Microphone acquisition failed. `permissionDenied` distinguishes user/browser denial. */
60
+ export class MicrophoneError extends BrowserAIError {
61
+ readonly permissionDenied: boolean;
62
+ constructor(cause: unknown) {
63
+ super(`Microphone error: ${formatError(cause)}`, { cause });
64
+ this.permissionDenied =
65
+ cause instanceof DOMException && (cause.name === "NotAllowedError" || cause.name === "SecurityError");
66
+ }
67
+ }
68
+
69
+ /**
70
+ * A voice-pipeline turn failure tagged with the component that failed. The streaming path runs
71
+ * generation and synthesis CONCURRENTLY, so the pipeline stage no longer identifies the failing
72
+ * component (the stage can be "speaking" when generation throws, or "thinking" when synthesis
73
+ * rejects) — the throw site knows and tags the label.
74
+ */
75
+ export class VoiceTurnError extends BrowserAIError {
76
+ readonly label: string;
77
+ constructor(label: string, cause: unknown) {
78
+ super(formatError(cause), { cause });
79
+ this.label = label;
80
+ }
81
+ }
package/src/events.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Minimal typed event emitter. Framework-agnostic and dependency-free; the event map type gives
3
+ * hosts full payload typing (`on("stagechange", ({ stage }) => …)`).
4
+ */
5
+ export type EventMap = Record<string, unknown>;
6
+
7
+ export type Listener<T> = (payload: T) => void;
8
+
9
+ export class TypedEmitter<Events extends EventMap> {
10
+ #listeners = new Map<keyof Events, Set<Listener<never>>>();
11
+
12
+ /** Subscribe. Returns an unsubscribe function. */
13
+ on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
14
+ let set = this.#listeners.get(event);
15
+ if (!set) {
16
+ set = new Set();
17
+ this.#listeners.set(event, set);
18
+ }
19
+ set.add(listener as Listener<never>);
20
+ return () => this.off(event, listener);
21
+ }
22
+
23
+ /** Subscribe for a single emission. Returns an unsubscribe function. */
24
+ once<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
25
+ const unsubscribe = this.on(event, (payload) => {
26
+ unsubscribe();
27
+ listener(payload);
28
+ });
29
+ return unsubscribe;
30
+ }
31
+
32
+ off<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void {
33
+ this.#listeners.get(event)?.delete(listener as Listener<never>);
34
+ }
35
+
36
+ protected emit<K extends keyof Events>(event: K, payload: Events[K]): void {
37
+ const set = this.#listeners.get(event);
38
+ if (!set) return;
39
+ // Copy so a listener unsubscribing (or subscribing) mid-emit cannot skip/duplicate deliveries.
40
+ for (const listener of [...set]) {
41
+ try {
42
+ (listener as Listener<Events[K]>)(payload);
43
+ } catch {
44
+ // A throwing listener must not break the engine or starve other listeners.
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Remove every listener (all events). */
50
+ removeAllListeners(): void {
51
+ this.#listeners.clear();
52
+ }
53
+ }
@@ -0,0 +1,39 @@
1
+ import type { ResponseFormat } from "@mlc-ai/web-llm";
2
+ import type { RuntimePreset } from "./models.js";
3
+ import { stripSchemaMetadata } from "./schema.js";
4
+
5
+ export type JsonMode = "schema" | "json_object" | "none";
6
+
7
+ export type RuntimeParameters = RuntimePreset & {
8
+ jsonMode: JsonMode;
9
+ latencyBreakdown: boolean;
10
+ };
11
+
12
+ export function runtimeParamsFromPreset(preset: RuntimePreset): RuntimeParameters {
13
+ return {
14
+ ...preset,
15
+ jsonMode: "schema",
16
+ latencyBreakdown: true,
17
+ };
18
+ }
19
+
20
+ export function buildResponseFormat(mode: JsonMode, schema: unknown): ResponseFormat | undefined {
21
+ if (mode === "none") return undefined;
22
+ if (mode === "json_object") return { type: "json_object" };
23
+
24
+ return {
25
+ type: "json_object",
26
+ schema: JSON.stringify(stripSchemaMetadata(schema)),
27
+ };
28
+ }
29
+
30
+ export function clampNumber(value: number, min: number, max: number): number {
31
+ if (!Number.isFinite(value)) return min;
32
+ return Math.min(max, Math.max(min, value));
33
+ }
34
+
35
+ export function parseOptionalInteger(value: string): number | null {
36
+ if (!value.trim()) return null;
37
+ const parsed = Number.parseInt(value, 10);
38
+ return Number.isFinite(parsed) ? parsed : null;
39
+ }
@@ -0,0 +1,193 @@
1
+ import type { ModelPreset } from "./models.js";
2
+ import { formatMB } from "./models.js";
3
+
4
+ type WebGpuLimitMap = Record<string, number | string>;
5
+
6
+ export type HardwareSnapshot = {
7
+ webgpuSupported: boolean;
8
+ webgpuReason: string;
9
+ adapterInfo?: Record<string, unknown>;
10
+ features: string[];
11
+ limits: WebGpuLimitMap;
12
+ deviceMemoryGB?: number;
13
+ logicalProcessors?: number;
14
+ storageQuotaBytes?: number;
15
+ storageUsageBytes?: number;
16
+ secureContext: boolean;
17
+ crossOriginIsolated: boolean;
18
+ userAgent: string;
19
+ platform?: string;
20
+ };
21
+
22
+ const LIMIT_NAMES = [
23
+ "maxBufferSize",
24
+ "maxStorageBufferBindingSize",
25
+ "maxComputeInvocationsPerWorkgroup",
26
+ "maxComputeWorkgroupSizeX",
27
+ "maxComputeWorkgroupSizeY",
28
+ "maxComputeWorkgroupsPerDimension",
29
+ "maxBindGroups",
30
+ "maxBindingsPerBindGroup",
31
+ "maxStorageBuffersPerShaderStage",
32
+ "maxUniformBuffersPerShaderStage",
33
+ "maxTextureDimension2D",
34
+ ];
35
+
36
+ export async function collectHardwareSnapshot(): Promise<HardwareSnapshot> {
37
+ const nav = navigator as Navigator & {
38
+ deviceMemory?: number;
39
+ userAgentData?: { platform?: string };
40
+ };
41
+
42
+ // Read off globalThis (not window) so the probe also works in a worker global scope.
43
+ const scope = globalThis as typeof globalThis & { isSecureContext?: boolean; crossOriginIsolated?: boolean };
44
+
45
+ const base: HardwareSnapshot = {
46
+ webgpuSupported: false,
47
+ webgpuReason: "navigator.gpu is not available.",
48
+ features: [],
49
+ limits: {},
50
+ deviceMemoryGB: nav.deviceMemory,
51
+ logicalProcessors: nav.hardwareConcurrency,
52
+ secureContext: scope.isSecureContext === true,
53
+ crossOriginIsolated: scope.crossOriginIsolated === true,
54
+ userAgent: navigator.userAgent,
55
+ platform: nav.userAgentData?.platform ?? navigator.platform,
56
+ };
57
+
58
+ try {
59
+ const estimate = await navigator.storage?.estimate?.();
60
+ base.storageQuotaBytes = estimate?.quota;
61
+ base.storageUsageBytes = estimate?.usage;
62
+ } catch {
63
+ // Storage quota is a best-effort browser hint; ignore unsupported/blocked cases.
64
+ }
65
+
66
+ if (!("gpu" in navigator)) {
67
+ return base;
68
+ }
69
+
70
+ try {
71
+ const adapter = await navigator.gpu.requestAdapter();
72
+ if (!adapter) {
73
+ return { ...base, webgpuReason: "WebGPU exists, but no adapter was returned." };
74
+ }
75
+
76
+ const limits = adapter.limits as unknown as Record<string, number>;
77
+ const pickedLimits: WebGpuLimitMap = {};
78
+ for (const name of LIMIT_NAMES) {
79
+ if (typeof limits[name] === "number") {
80
+ pickedLimits[name] = limits[name];
81
+ }
82
+ }
83
+
84
+ let canCreateDevice = false;
85
+ try {
86
+ const device = await adapter.requestDevice();
87
+ canCreateDevice = true;
88
+ device.destroy();
89
+ } catch {
90
+ canCreateDevice = false;
91
+ }
92
+
93
+ const info = adapter.info as unknown as Record<string, unknown> | undefined;
94
+ return {
95
+ ...base,
96
+ webgpuSupported: canCreateDevice,
97
+ webgpuReason: canCreateDevice ? "WebGPU adapter and device are available." : "Adapter exists, but requestDevice() failed.",
98
+ adapterInfo: info,
99
+ features: Array.from(adapter.features).sort(),
100
+ limits: pickedLimits,
101
+ };
102
+ } catch (error) {
103
+ return {
104
+ ...base,
105
+ webgpuReason: error instanceof Error ? error.message : String(error),
106
+ };
107
+ }
108
+ }
109
+
110
+ export function modelSafetyHint(snapshot: HardwareSnapshot, model: ModelPreset): string {
111
+ const vram = formatMB(model.vramRequiredMB);
112
+ const maxStorage = numberLimit(snapshot, "maxStorageBufferBindingSize");
113
+ const deviceMemory = snapshot.deviceMemoryGB;
114
+
115
+ const sourceText =
116
+ model.vramSource === "estimated"
117
+ ? `This custom model is configured with an estimated GPU memory requirement of about ${vram}.`
118
+ : `WebLLM metadata says this model requires about ${vram} of GPU memory.`;
119
+
120
+ const parts = [
121
+ sourceText,
122
+ "Browsers do not expose total or free VRAM, so this is a heuristic; the actual load attempt is the definitive test.",
123
+ ];
124
+
125
+ if (typeof maxStorage === "number") {
126
+ parts.push(`Adapter maxStorageBufferBindingSize is ${formatBytes(maxStorage)}.`);
127
+ }
128
+
129
+ if (typeof deviceMemory === "number") {
130
+ parts.push(`navigator.deviceMemory reports about ${deviceMemory} GB of system memory, but this is not VRAM.`);
131
+ }
132
+
133
+ return parts.join(" ");
134
+ }
135
+
136
+ export function formatHardwareSnapshot(snapshot: HardwareSnapshot, model?: ModelPreset): string {
137
+ const lines = [
138
+ `WebGPU: ${snapshot.webgpuSupported ? "available" : "unavailable"}`,
139
+ `Reason: ${snapshot.webgpuReason}`,
140
+ `Secure context: ${snapshot.secureContext ? "yes" : "no"}`,
141
+ `crossOriginIsolated: ${snapshot.crossOriginIsolated ? "yes" : "no"}`,
142
+ `Logical processors: ${snapshot.logicalProcessors ?? "unknown"}`,
143
+ `Approx system memory: ${snapshot.deviceMemoryGB ? `${snapshot.deviceMemoryGB} GB` : "unavailable"}`,
144
+ `Browser storage: ${formatStorage(snapshot)}`,
145
+ `Platform: ${snapshot.platform ?? "unknown"}`,
146
+ ];
147
+
148
+ if (snapshot.adapterInfo && Object.keys(snapshot.adapterInfo).length > 0) {
149
+ lines.push("", "GPU adapter info:", JSON.stringify(snapshot.adapterInfo, null, 2));
150
+ }
151
+
152
+ if (snapshot.features.length > 0) {
153
+ lines.push("", `GPU features (${snapshot.features.length}):`, snapshot.features.join(", "));
154
+ }
155
+
156
+ if (Object.keys(snapshot.limits).length > 0) {
157
+ lines.push("", "Selected GPU limits:");
158
+ for (const [key, value] of Object.entries(snapshot.limits)) {
159
+ lines.push(` ${key}: ${typeof value === "number" && key.toLowerCase().includes("size") ? formatBytes(value) : value}`);
160
+ }
161
+ }
162
+
163
+ if (model) {
164
+ lines.push("", "Selected model safety hint:", modelSafetyHint(snapshot, model));
165
+ }
166
+
167
+ return lines.join("\n");
168
+ }
169
+
170
+ export function formatBytes(bytes: number): string {
171
+ if (!Number.isFinite(bytes)) return "unknown";
172
+ const units = ["B", "KB", "MB", "GB", "TB"];
173
+ let value = bytes;
174
+ let unit = 0;
175
+ while (value >= 1024 && unit < units.length - 1) {
176
+ value /= 1024;
177
+ unit += 1;
178
+ }
179
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
180
+ }
181
+
182
+ function formatStorage(snapshot: HardwareSnapshot): string {
183
+ if (typeof snapshot.storageQuotaBytes !== "number") {
184
+ return "unavailable";
185
+ }
186
+ const usage = typeof snapshot.storageUsageBytes === "number" ? formatBytes(snapshot.storageUsageBytes) : "unknown";
187
+ return `${usage} used / ${formatBytes(snapshot.storageQuotaBytes)} quota`;
188
+ }
189
+
190
+ function numberLimit(snapshot: HardwareSnapshot, key: string): number | undefined {
191
+ const value = snapshot.limits[key];
192
+ return typeof value === "number" ? value : undefined;
193
+ }