@sauravpanda/flare 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/demo/README.md +40 -0
- package/demo/index.html +1767 -0
- package/js/index.ts +91 -0
- package/js/types.ts +136 -0
- package/js/webtransport-loader.js +126 -0
- package/js/worker.ts +159 -0
- package/package.json +58 -0
- package/pkg/flare_web.d.ts +1164 -0
- package/pkg/flare_web.js +2790 -0
- package/pkg/flare_web_bg.wasm +0 -0
- package/pkg/flare_web_bg.wasm.d.ts +105 -0
- package/pkg/package.json +27 -0
package/demo/index.html
ADDED
|
@@ -0,0 +1,1767 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<!-- COOP/COEP headers required for SharedArrayBuffer (multi-threaded WASM).
|
|
7
|
+
These can also be set via HTTP response headers on the server. -->
|
|
8
|
+
<meta http-equiv="Cross-Origin-Opener-Policy" content="same-origin">
|
|
9
|
+
<meta http-equiv="Cross-Origin-Embedder-Policy" content="require-corp">
|
|
10
|
+
<title>Flare LLM Browser Demo</title>
|
|
11
|
+
<style>
|
|
12
|
+
/* ---- Design tokens ---- */
|
|
13
|
+
:root {
|
|
14
|
+
color-scheme: light dark;
|
|
15
|
+
--bg: #ffffff;
|
|
16
|
+
--bg-surface: #f7f8fa;
|
|
17
|
+
--bg-surface-alt: #eef0f4;
|
|
18
|
+
--fg: #1a1a2e;
|
|
19
|
+
--fg-dim: #6b7280;
|
|
20
|
+
--fg-faint: #9ca3af;
|
|
21
|
+
--border: #e2e5ea;
|
|
22
|
+
--border-focus: #4f8fff;
|
|
23
|
+
--accent: #4f8fff;
|
|
24
|
+
--accent-bg: #4f8fff14;
|
|
25
|
+
--accent-border: #4f8fff44;
|
|
26
|
+
--success: #22c55e;
|
|
27
|
+
--success-bg: #22c55e14;
|
|
28
|
+
--success-border: #22c55e44;
|
|
29
|
+
--warn: #f59e0b;
|
|
30
|
+
--warn-bg: #f59e0b14;
|
|
31
|
+
--warn-border: #f59e0b44;
|
|
32
|
+
--error: #ef4444;
|
|
33
|
+
--error-bg: #ef444414;
|
|
34
|
+
--error-border: #ef444444;
|
|
35
|
+
--radius: 10px;
|
|
36
|
+
--radius-sm: 6px;
|
|
37
|
+
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Menlo', monospace;
|
|
38
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
39
|
+
}
|
|
40
|
+
@media (prefers-color-scheme: dark) {
|
|
41
|
+
:root {
|
|
42
|
+
--bg: #0f1117;
|
|
43
|
+
--bg-surface: #1a1d27;
|
|
44
|
+
--bg-surface-alt: #22262f;
|
|
45
|
+
--fg: #e5e7eb;
|
|
46
|
+
--fg-dim: #9ca3af;
|
|
47
|
+
--fg-faint: #6b7280;
|
|
48
|
+
--border: #2d3140;
|
|
49
|
+
--border-focus: #4f8fff;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ---- Reset & base ---- */
|
|
54
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
55
|
+
body {
|
|
56
|
+
background: var(--bg);
|
|
57
|
+
color: var(--fg);
|
|
58
|
+
max-width: 860px;
|
|
59
|
+
margin: 0 auto;
|
|
60
|
+
padding: 1.5rem 1rem 3rem;
|
|
61
|
+
line-height: 1.6;
|
|
62
|
+
}
|
|
63
|
+
h1, h2, h3 { margin: 0; font-weight: 600; }
|
|
64
|
+
a { color: var(--accent); text-decoration: none; }
|
|
65
|
+
a:hover { text-decoration: underline; }
|
|
66
|
+
|
|
67
|
+
/* ---- Header ---- */
|
|
68
|
+
.header { margin-bottom: 1.5rem; }
|
|
69
|
+
.header h1 { font-size: 1.75rem; letter-spacing: -0.02em; }
|
|
70
|
+
.header .subtitle {
|
|
71
|
+
color: var(--fg-dim);
|
|
72
|
+
margin: 0.25rem 0 0;
|
|
73
|
+
font-size: 0.95rem;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ---- Panels ---- */
|
|
77
|
+
.panel {
|
|
78
|
+
background: var(--bg-surface);
|
|
79
|
+
border: 1px solid var(--border);
|
|
80
|
+
border-radius: var(--radius);
|
|
81
|
+
padding: 1.25rem;
|
|
82
|
+
margin-bottom: 1rem;
|
|
83
|
+
}
|
|
84
|
+
.panel h3 {
|
|
85
|
+
font-size: 0.95rem;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
letter-spacing: 0.04em;
|
|
88
|
+
color: var(--fg-dim);
|
|
89
|
+
margin-bottom: 0.75rem;
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
gap: 0.5rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- Buttons ---- */
|
|
96
|
+
button {
|
|
97
|
+
font: inherit;
|
|
98
|
+
font-size: 0.875rem;
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
padding: 0.5rem 1rem;
|
|
101
|
+
border: 1px solid var(--border);
|
|
102
|
+
border-radius: var(--radius-sm);
|
|
103
|
+
background: var(--bg-surface-alt);
|
|
104
|
+
color: var(--fg);
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
transition: background 0.15s, border-color 0.15s;
|
|
107
|
+
}
|
|
108
|
+
button:hover:not(:disabled) {
|
|
109
|
+
border-color: var(--border-focus);
|
|
110
|
+
background: var(--accent-bg);
|
|
111
|
+
}
|
|
112
|
+
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
113
|
+
button.primary {
|
|
114
|
+
background: var(--accent);
|
|
115
|
+
color: #fff;
|
|
116
|
+
border-color: var(--accent);
|
|
117
|
+
}
|
|
118
|
+
button.primary:hover:not(:disabled) {
|
|
119
|
+
background: #3d7ae6;
|
|
120
|
+
border-color: #3d7ae6;
|
|
121
|
+
}
|
|
122
|
+
button.danger {
|
|
123
|
+
color: var(--error);
|
|
124
|
+
border-color: var(--error-border);
|
|
125
|
+
}
|
|
126
|
+
button.danger:hover:not(:disabled) {
|
|
127
|
+
background: var(--error-bg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---- Inputs ---- */
|
|
131
|
+
input[type="text"], input[type="url"], input[type="number"], textarea, select {
|
|
132
|
+
font: inherit;
|
|
133
|
+
font-size: 0.9rem;
|
|
134
|
+
padding: 0.5rem 0.7rem;
|
|
135
|
+
border: 1px solid var(--border);
|
|
136
|
+
border-radius: var(--radius-sm);
|
|
137
|
+
background: var(--bg);
|
|
138
|
+
color: var(--fg);
|
|
139
|
+
width: 100%;
|
|
140
|
+
margin-bottom: 0.5rem;
|
|
141
|
+
transition: border-color 0.15s;
|
|
142
|
+
}
|
|
143
|
+
input:focus, textarea:focus, select:focus {
|
|
144
|
+
outline: none;
|
|
145
|
+
border-color: var(--border-focus);
|
|
146
|
+
box-shadow: 0 0 0 3px var(--accent-bg);
|
|
147
|
+
}
|
|
148
|
+
textarea { resize: vertical; min-height: 3.5rem; }
|
|
149
|
+
|
|
150
|
+
/* ---- Badges ---- */
|
|
151
|
+
.badge {
|
|
152
|
+
display: inline-flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
font-size: 0.7rem;
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
text-transform: uppercase;
|
|
157
|
+
letter-spacing: 0.04em;
|
|
158
|
+
padding: 0.15rem 0.45rem;
|
|
159
|
+
border-radius: 4px;
|
|
160
|
+
border: 1px solid var(--border);
|
|
161
|
+
background: var(--bg-surface-alt);
|
|
162
|
+
}
|
|
163
|
+
.badge.ok { background: var(--success-bg); border-color: var(--success-border); color: var(--success); }
|
|
164
|
+
.badge.warn { background: var(--warn-bg); border-color: var(--warn-border); color: var(--warn); }
|
|
165
|
+
.badge.info { background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent); }
|
|
166
|
+
.badge.error{ background: var(--error-bg); border-color: var(--error-border); color: var(--error); }
|
|
167
|
+
|
|
168
|
+
/* ---- Stats text ---- */
|
|
169
|
+
.stats { font-size: 0.82rem; color: var(--fg-dim); margin: 0.25rem 0; }
|
|
170
|
+
.error { color: var(--error); }
|
|
171
|
+
.field-label { font-size: 0.82rem; color: var(--fg-dim); margin-bottom: 0.25rem; }
|
|
172
|
+
|
|
173
|
+
/* ---- Tabs ---- */
|
|
174
|
+
.tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; }
|
|
175
|
+
.tab-btn {
|
|
176
|
+
font-size: 0.82rem;
|
|
177
|
+
padding: 0.3rem 0.7rem;
|
|
178
|
+
border-radius: var(--radius-sm);
|
|
179
|
+
border: 1px solid transparent;
|
|
180
|
+
background: transparent;
|
|
181
|
+
}
|
|
182
|
+
.tab-btn.active {
|
|
183
|
+
background: var(--accent-bg);
|
|
184
|
+
border-color: var(--accent-border);
|
|
185
|
+
color: var(--accent);
|
|
186
|
+
}
|
|
187
|
+
.tab-panel { display: none; }
|
|
188
|
+
.tab-panel.active { display: block; }
|
|
189
|
+
|
|
190
|
+
/* ---- Progress bar ---- */
|
|
191
|
+
.progress-wrap { margin: 0.75rem 0; display: none; }
|
|
192
|
+
.progress-wrap.visible { display: block; }
|
|
193
|
+
.progress-bar-outer {
|
|
194
|
+
width: 100%;
|
|
195
|
+
height: 6px;
|
|
196
|
+
background: var(--bg-surface-alt);
|
|
197
|
+
border-radius: 3px;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
}
|
|
200
|
+
.progress-bar-inner {
|
|
201
|
+
height: 100%;
|
|
202
|
+
width: 0%;
|
|
203
|
+
background: var(--accent);
|
|
204
|
+
border-radius: 3px;
|
|
205
|
+
transition: width 0.2s ease;
|
|
206
|
+
}
|
|
207
|
+
.progress-bar-inner.indeterminate {
|
|
208
|
+
width: 30%;
|
|
209
|
+
animation: indeterminate 1.2s infinite ease-in-out;
|
|
210
|
+
}
|
|
211
|
+
@keyframes indeterminate {
|
|
212
|
+
0% { transform: translateX(-100%); }
|
|
213
|
+
100% { transform: translateX(400%); }
|
|
214
|
+
}
|
|
215
|
+
.progress-label {
|
|
216
|
+
font-size: 0.78rem;
|
|
217
|
+
color: var(--fg-dim);
|
|
218
|
+
margin-top: 0.3rem;
|
|
219
|
+
display: flex;
|
|
220
|
+
justify-content: space-between;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ---- Skeleton shimmer ---- */
|
|
224
|
+
.skeleton {
|
|
225
|
+
background: linear-gradient(90deg, var(--bg-surface-alt) 25%, var(--bg-surface) 50%, var(--bg-surface-alt) 75%);
|
|
226
|
+
background-size: 200% 100%;
|
|
227
|
+
animation: shimmer 1.5s infinite;
|
|
228
|
+
border-radius: var(--radius-sm);
|
|
229
|
+
}
|
|
230
|
+
@keyframes shimmer {
|
|
231
|
+
0% { background-position: 200% 0; }
|
|
232
|
+
100% { background-position: -200% 0; }
|
|
233
|
+
}
|
|
234
|
+
.skeleton-line {
|
|
235
|
+
height: 0.8rem;
|
|
236
|
+
margin-bottom: 0.4rem;
|
|
237
|
+
border-radius: 3px;
|
|
238
|
+
}
|
|
239
|
+
.skeleton-line:last-child { width: 60%; }
|
|
240
|
+
|
|
241
|
+
/* ---- Performance dashboard ---- */
|
|
242
|
+
.perf-grid {
|
|
243
|
+
display: grid;
|
|
244
|
+
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
245
|
+
gap: 0.5rem;
|
|
246
|
+
}
|
|
247
|
+
.perf-card {
|
|
248
|
+
background: var(--bg);
|
|
249
|
+
border: 1px solid var(--border);
|
|
250
|
+
border-radius: var(--radius-sm);
|
|
251
|
+
padding: 0.6rem 0.75rem;
|
|
252
|
+
text-align: center;
|
|
253
|
+
}
|
|
254
|
+
.perf-card .value {
|
|
255
|
+
font-size: 1.3rem;
|
|
256
|
+
font-weight: 700;
|
|
257
|
+
font-variant-numeric: tabular-nums;
|
|
258
|
+
letter-spacing: -0.02em;
|
|
259
|
+
color: var(--fg);
|
|
260
|
+
}
|
|
261
|
+
.perf-card .value.accent { color: var(--accent); }
|
|
262
|
+
.perf-card .label {
|
|
263
|
+
font-size: 0.7rem;
|
|
264
|
+
text-transform: uppercase;
|
|
265
|
+
letter-spacing: 0.05em;
|
|
266
|
+
color: var(--fg-faint);
|
|
267
|
+
margin-top: 0.15rem;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ---- Model info grid ---- */
|
|
271
|
+
.model-info-grid {
|
|
272
|
+
display: grid;
|
|
273
|
+
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
274
|
+
gap: 0.4rem 1rem;
|
|
275
|
+
font-size: 0.85rem;
|
|
276
|
+
}
|
|
277
|
+
.model-info-grid dt {
|
|
278
|
+
color: var(--fg-faint);
|
|
279
|
+
font-size: 0.75rem;
|
|
280
|
+
text-transform: uppercase;
|
|
281
|
+
letter-spacing: 0.04em;
|
|
282
|
+
}
|
|
283
|
+
.model-info-grid dd {
|
|
284
|
+
margin: 0 0 0.3rem;
|
|
285
|
+
font-weight: 500;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---- Sampling controls ---- */
|
|
289
|
+
.sampling-controls {
|
|
290
|
+
display: grid;
|
|
291
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
292
|
+
gap: 0.5rem 1rem;
|
|
293
|
+
margin-bottom: 0.75rem;
|
|
294
|
+
}
|
|
295
|
+
.sampling-controls label {
|
|
296
|
+
font-size: 0.82rem;
|
|
297
|
+
color: var(--fg-dim);
|
|
298
|
+
}
|
|
299
|
+
.sampling-controls .slider-row {
|
|
300
|
+
display: flex;
|
|
301
|
+
align-items: center;
|
|
302
|
+
gap: 0.4rem;
|
|
303
|
+
}
|
|
304
|
+
.sampling-controls input[type="range"] {
|
|
305
|
+
flex: 1;
|
|
306
|
+
height: 4px;
|
|
307
|
+
accent-color: var(--accent);
|
|
308
|
+
}
|
|
309
|
+
.sampling-controls .slider-val {
|
|
310
|
+
font-size: 0.8rem;
|
|
311
|
+
font-family: var(--font-mono);
|
|
312
|
+
min-width: 2.5rem;
|
|
313
|
+
text-align: right;
|
|
314
|
+
color: var(--fg);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---- Chat thread ---- */
|
|
318
|
+
.chat-thread {
|
|
319
|
+
display: flex;
|
|
320
|
+
flex-direction: column;
|
|
321
|
+
gap: 0.75rem;
|
|
322
|
+
max-height: 480px;
|
|
323
|
+
overflow-y: auto;
|
|
324
|
+
padding: 0.5rem 0;
|
|
325
|
+
margin-bottom: 0.75rem;
|
|
326
|
+
scroll-behavior: smooth;
|
|
327
|
+
}
|
|
328
|
+
.chat-thread:empty::before {
|
|
329
|
+
content: 'Your conversation will appear here.';
|
|
330
|
+
color: var(--fg-faint);
|
|
331
|
+
font-size: 0.9rem;
|
|
332
|
+
text-align: center;
|
|
333
|
+
padding: 2rem 0;
|
|
334
|
+
display: block;
|
|
335
|
+
}
|
|
336
|
+
.msg-wrap { display: flex; flex-direction: column; }
|
|
337
|
+
.msg {
|
|
338
|
+
max-width: 88%;
|
|
339
|
+
padding: 0.7rem 1rem;
|
|
340
|
+
border-radius: 14px;
|
|
341
|
+
font-size: 0.92rem;
|
|
342
|
+
line-height: 1.6;
|
|
343
|
+
word-break: break-word;
|
|
344
|
+
}
|
|
345
|
+
.msg.user {
|
|
346
|
+
align-self: flex-end;
|
|
347
|
+
background: var(--accent);
|
|
348
|
+
color: #fff;
|
|
349
|
+
border-bottom-right-radius: 4px;
|
|
350
|
+
}
|
|
351
|
+
.msg.assistant {
|
|
352
|
+
align-self: flex-start;
|
|
353
|
+
background: var(--bg-surface-alt);
|
|
354
|
+
border: 1px solid var(--border);
|
|
355
|
+
border-bottom-left-radius: 4px;
|
|
356
|
+
}
|
|
357
|
+
.msg.assistant.streaming {
|
|
358
|
+
border-color: var(--accent-border);
|
|
359
|
+
}
|
|
360
|
+
.msg-role {
|
|
361
|
+
font-size: 0.68rem;
|
|
362
|
+
text-transform: uppercase;
|
|
363
|
+
letter-spacing: 0.05em;
|
|
364
|
+
color: var(--fg-faint);
|
|
365
|
+
margin-bottom: 0.2rem;
|
|
366
|
+
padding: 0 0.3rem;
|
|
367
|
+
}
|
|
368
|
+
.msg-wrap:has(.msg.user) .msg-role { text-align: right; }
|
|
369
|
+
|
|
370
|
+
/* ---- Markdown in assistant messages ---- */
|
|
371
|
+
.msg.assistant code {
|
|
372
|
+
font-family: var(--font-mono);
|
|
373
|
+
font-size: 0.84em;
|
|
374
|
+
background: var(--bg);
|
|
375
|
+
padding: 0.15em 0.35em;
|
|
376
|
+
border-radius: 3px;
|
|
377
|
+
border: 1px solid var(--border);
|
|
378
|
+
}
|
|
379
|
+
.msg.assistant pre {
|
|
380
|
+
background: var(--bg);
|
|
381
|
+
border: 1px solid var(--border);
|
|
382
|
+
border-radius: var(--radius-sm);
|
|
383
|
+
padding: 0.7rem 0.8rem;
|
|
384
|
+
overflow-x: auto;
|
|
385
|
+
margin: 0.5rem 0;
|
|
386
|
+
font-size: 0.82em;
|
|
387
|
+
line-height: 1.5;
|
|
388
|
+
}
|
|
389
|
+
.msg.assistant pre code {
|
|
390
|
+
background: none;
|
|
391
|
+
border: none;
|
|
392
|
+
padding: 0;
|
|
393
|
+
}
|
|
394
|
+
.msg.assistant ul, .msg.assistant ol {
|
|
395
|
+
margin: 0.4rem 0;
|
|
396
|
+
padding-left: 1.2rem;
|
|
397
|
+
}
|
|
398
|
+
.msg.assistant li { margin-bottom: 0.2rem; }
|
|
399
|
+
.msg.assistant p { margin: 0.3rem 0; }
|
|
400
|
+
.msg.assistant p:first-child { margin-top: 0; }
|
|
401
|
+
.msg.assistant p:last-child { margin-bottom: 0; }
|
|
402
|
+
.msg.assistant strong { font-weight: 600; }
|
|
403
|
+
.msg.assistant em { font-style: italic; }
|
|
404
|
+
|
|
405
|
+
/* ---- Compose bar ---- */
|
|
406
|
+
.compose-bar {
|
|
407
|
+
display: flex;
|
|
408
|
+
gap: 0.5rem;
|
|
409
|
+
align-items: flex-end;
|
|
410
|
+
}
|
|
411
|
+
.compose-bar textarea {
|
|
412
|
+
flex: 1;
|
|
413
|
+
margin-bottom: 0;
|
|
414
|
+
}
|
|
415
|
+
.compose-actions {
|
|
416
|
+
display: flex;
|
|
417
|
+
gap: 0.35rem;
|
|
418
|
+
flex-wrap: wrap;
|
|
419
|
+
margin-top: 0.5rem;
|
|
420
|
+
align-items: center;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* ---- Loading skeleton ---- */
|
|
424
|
+
.loading-skeleton {
|
|
425
|
+
padding: 1rem;
|
|
426
|
+
display: none;
|
|
427
|
+
}
|
|
428
|
+
.loading-skeleton.visible { display: block; }
|
|
429
|
+
|
|
430
|
+
/* ---- Cache list ---- */
|
|
431
|
+
.cache-item {
|
|
432
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
433
|
+
padding: 0.4rem 0; border-bottom: 1px solid var(--border); gap: 0.5rem;
|
|
434
|
+
}
|
|
435
|
+
.cache-item:last-child { border-bottom: none; }
|
|
436
|
+
.cache-item-name { font-size: 0.88rem; word-break: break-all; flex: 1; }
|
|
437
|
+
.cache-item-size { font-size: 0.8rem; color: var(--fg-dim); white-space: nowrap; }
|
|
438
|
+
.cache-item-actions { display: flex; gap: 0.3rem; }
|
|
439
|
+
.cache-item-actions button { padding: 0.2rem 0.5rem; font-size: 0.78rem; }
|
|
440
|
+
|
|
441
|
+
/* ---- Footer ---- */
|
|
442
|
+
.footer {
|
|
443
|
+
text-align: center;
|
|
444
|
+
color: var(--fg-faint);
|
|
445
|
+
font-size: 0.82rem;
|
|
446
|
+
margin-top: 2rem;
|
|
447
|
+
padding-top: 1rem;
|
|
448
|
+
border-top: 1px solid var(--border);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* ---- Two-column layout on wider screens ---- */
|
|
452
|
+
.two-col {
|
|
453
|
+
display: grid;
|
|
454
|
+
grid-template-columns: 1fr;
|
|
455
|
+
gap: 1rem;
|
|
456
|
+
}
|
|
457
|
+
@media (min-width: 640px) {
|
|
458
|
+
.two-col { grid-template-columns: 1fr 1fr; }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ---- Responsive ---- */
|
|
462
|
+
@media (max-width: 480px) {
|
|
463
|
+
body { padding: 1rem 0.75rem 2rem; }
|
|
464
|
+
.perf-grid { grid-template-columns: repeat(3, 1fr); }
|
|
465
|
+
.sampling-controls { grid-template-columns: 1fr; }
|
|
466
|
+
.model-info-grid { grid-template-columns: 1fr 1fr; }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* ---- Checkbox / toggle ---- */
|
|
470
|
+
label.checkbox {
|
|
471
|
+
display: flex; align-items: center; gap: 0.4rem;
|
|
472
|
+
font-size: 0.88rem; cursor: pointer;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* ---- Collapsible ---- */
|
|
476
|
+
details summary {
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
font-size: 0.85rem;
|
|
479
|
+
color: var(--fg-dim);
|
|
480
|
+
margin-bottom: 0.5rem;
|
|
481
|
+
}
|
|
482
|
+
details summary:hover { color: var(--fg); }
|
|
483
|
+
</style>
|
|
484
|
+
</head>
|
|
485
|
+
<body>
|
|
486
|
+
<!-- ==================== HEADER ==================== -->
|
|
487
|
+
<div class="header">
|
|
488
|
+
<h1>Flare LLM</h1>
|
|
489
|
+
<p class="subtitle">WASM-first LLM inference, running entirely in your browser.</p>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<!-- ==================== STATUS ==================== -->
|
|
493
|
+
<div class="panel">
|
|
494
|
+
<h3>Status</h3>
|
|
495
|
+
<p id="status">Initializing WASM module...</p>
|
|
496
|
+
<p class="stats" id="env"></p>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<!-- ==================== PERFORMANCE DASHBOARD ==================== -->
|
|
500
|
+
<div class="panel" id="perf-panel" style="display:none;">
|
|
501
|
+
<h3>Performance</h3>
|
|
502
|
+
<div class="perf-grid">
|
|
503
|
+
<div class="perf-card">
|
|
504
|
+
<div class="value accent" id="perf-tps">--</div>
|
|
505
|
+
<div class="label">Tok/s</div>
|
|
506
|
+
</div>
|
|
507
|
+
<div class="perf-card">
|
|
508
|
+
<div class="value" id="perf-ttft">--</div>
|
|
509
|
+
<div class="label">TTFT (ms)</div>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="perf-card">
|
|
512
|
+
<div class="value" id="perf-tokens">0</div>
|
|
513
|
+
<div class="label">Tokens</div>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="perf-card">
|
|
516
|
+
<div class="value" id="perf-mem">--</div>
|
|
517
|
+
<div class="label">Memory (MB)</div>
|
|
518
|
+
</div>
|
|
519
|
+
<div class="perf-card">
|
|
520
|
+
<div class="value" id="perf-backend">--</div>
|
|
521
|
+
<div class="label">Backend</div>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
<!-- ==================== LOAD MODEL ==================== -->
|
|
527
|
+
<div class="panel">
|
|
528
|
+
<h3>1. Load a model</h3>
|
|
529
|
+
<div class="tabs">
|
|
530
|
+
<button class="tab-btn active" data-tab="file">Local file</button>
|
|
531
|
+
<button class="tab-btn" data-tab="url">From URL</button>
|
|
532
|
+
</div>
|
|
533
|
+
<div id="tab-file" class="tab-panel active">
|
|
534
|
+
<p class="stats">Select a GGUF file. Try SmolLM2-135M Q8_0 (~138 MB).</p>
|
|
535
|
+
<input type="file" id="file-input" accept=".gguf" disabled>
|
|
536
|
+
</div>
|
|
537
|
+
<div id="tab-url" class="tab-panel">
|
|
538
|
+
<p class="stats">Paste a direct URL to a GGUF file. Download progress shown in real time.</p>
|
|
539
|
+
<input type="url" id="url-input" placeholder="https://example.com/model.gguf" disabled>
|
|
540
|
+
<button id="url-load" disabled>Load from URL</button>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<!-- Progress bar -->
|
|
544
|
+
<div class="progress-wrap" id="progress-wrap">
|
|
545
|
+
<div class="progress-bar-outer">
|
|
546
|
+
<div class="progress-bar-inner" id="progress-bar"></div>
|
|
547
|
+
</div>
|
|
548
|
+
<div class="progress-label">
|
|
549
|
+
<span id="progress-label-text">Connecting...</span>
|
|
550
|
+
<span id="progress-label-speed"></span>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<!-- Loading skeleton -->
|
|
555
|
+
<div class="loading-skeleton" id="model-skeleton">
|
|
556
|
+
<div class="skeleton skeleton-line" style="width:90%"></div>
|
|
557
|
+
<div class="skeleton skeleton-line" style="width:70%"></div>
|
|
558
|
+
<div class="skeleton skeleton-line" style="width:50%"></div>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
<!-- ==================== CACHED MODELS (OPFS) ==================== -->
|
|
563
|
+
<div class="panel" id="cache-panel" style="display:none;">
|
|
564
|
+
<details id="cache-section">
|
|
565
|
+
<summary style="cursor:pointer;font-weight:600;">Cached models (OPFS) <span class="badge" id="cache-count" style="margin-left:0.3rem;">0</span></summary>
|
|
566
|
+
<ul id="cache-list" style="list-style:none;padding:0;margin:0.5rem 0;"></ul>
|
|
567
|
+
<p class="stats" id="cache-stats"></p>
|
|
568
|
+
</details>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
<!-- ==================== MODEL INFO ==================== -->
|
|
572
|
+
<div class="panel" id="model-info-panel" style="display:none;">
|
|
573
|
+
<h3>Model Info</h3>
|
|
574
|
+
<dl class="model-info-grid" id="model-info-grid"></dl>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
<!-- ==================== LOAD TOKENIZER ==================== -->
|
|
578
|
+
<div class="panel">
|
|
579
|
+
<h3>2. Tokenizer <span class="badge warn" id="tok-badge">not loaded</span></h3>
|
|
580
|
+
<p class="stats">Load a HuggingFace <code>tokenizer.json</code> for decoded text output.
|
|
581
|
+
Without it, raw token IDs are shown.</p>
|
|
582
|
+
<div class="tabs">
|
|
583
|
+
<button class="tab-btn active" data-tab="tok-file">Local file</button>
|
|
584
|
+
<button class="tab-btn" data-tab="tok-url">From URL</button>
|
|
585
|
+
</div>
|
|
586
|
+
<div id="tab-tok-file" class="tab-panel active">
|
|
587
|
+
<input type="file" id="tok-file-input" accept=".json" disabled>
|
|
588
|
+
</div>
|
|
589
|
+
<div id="tab-tok-url" class="tab-panel">
|
|
590
|
+
<input type="url" id="tok-url-input" placeholder="https://example.com/tokenizer.json" disabled>
|
|
591
|
+
<button id="tok-url-load" disabled>Load tokenizer</button>
|
|
592
|
+
</div>
|
|
593
|
+
<p class="stats" id="tok-info"></p>
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
<!-- ==================== CHAT ==================== -->
|
|
597
|
+
<div class="panel">
|
|
598
|
+
<h3>3. Chat</h3>
|
|
599
|
+
|
|
600
|
+
<!-- Chat mode toggle -->
|
|
601
|
+
<div style="margin-bottom:0.75rem;">
|
|
602
|
+
<label class="checkbox">
|
|
603
|
+
<input type="checkbox" id="chat-mode" checked>
|
|
604
|
+
Apply chat template
|
|
605
|
+
<span class="badge info" id="template-badge" style="display:none"></span>
|
|
606
|
+
</label>
|
|
607
|
+
<p class="stats" style="margin:0.15rem 0 0 1.4rem;">
|
|
608
|
+
Wraps your prompt in the model's instruction format. Disable for raw completion.
|
|
609
|
+
</p>
|
|
610
|
+
</div>
|
|
611
|
+
|
|
612
|
+
<!-- System prompt -->
|
|
613
|
+
<div id="system-prompt-row" style="margin-bottom:0.75rem;">
|
|
614
|
+
<div class="field-label">System prompt (optional, applied on first turn)</div>
|
|
615
|
+
<textarea id="system-input" placeholder="You are a helpful assistant." disabled rows="2"></textarea>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<!-- Sampling controls (collapsible) -->
|
|
619
|
+
<details style="margin-bottom:0.75rem;">
|
|
620
|
+
<summary>Sampling parameters</summary>
|
|
621
|
+
<div class="sampling-controls" id="sampling-controls">
|
|
622
|
+
<div>
|
|
623
|
+
<label>Temperature</label>
|
|
624
|
+
<div class="slider-row">
|
|
625
|
+
<input type="range" id="param-temp" min="0" max="2" step="0.05" value="0.7">
|
|
626
|
+
<span class="slider-val" id="val-temp">0.70</span>
|
|
627
|
+
</div>
|
|
628
|
+
</div>
|
|
629
|
+
<div>
|
|
630
|
+
<label>Top-p</label>
|
|
631
|
+
<div class="slider-row">
|
|
632
|
+
<input type="range" id="param-topp" min="0" max="1" step="0.01" value="0.95">
|
|
633
|
+
<span class="slider-val" id="val-topp">0.95</span>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
<div>
|
|
637
|
+
<label>Top-k</label>
|
|
638
|
+
<div class="slider-row">
|
|
639
|
+
<input type="range" id="param-topk" min="0" max="100" step="1" value="40">
|
|
640
|
+
<span class="slider-val" id="val-topk">40</span>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div>
|
|
644
|
+
<label>Repeat penalty</label>
|
|
645
|
+
<div class="slider-row">
|
|
646
|
+
<input type="range" id="param-repeat" min="1" max="2" step="0.05" value="1.1">
|
|
647
|
+
<span class="slider-val" id="val-repeat">1.10</span>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
<div>
|
|
651
|
+
<label>Max tokens</label>
|
|
652
|
+
<div class="slider-row">
|
|
653
|
+
<input type="range" id="max-tokens" min="16" max="512" step="16" value="128">
|
|
654
|
+
<span class="slider-val" id="val-maxtokens">128</span>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
</details>
|
|
659
|
+
|
|
660
|
+
<!-- Conversation thread -->
|
|
661
|
+
<div class="chat-thread" id="chat"></div>
|
|
662
|
+
|
|
663
|
+
<!-- Compose area -->
|
|
664
|
+
<div>
|
|
665
|
+
<div class="field-label" style="display:flex;justify-content:space-between;align-items:baseline;">
|
|
666
|
+
<span>Your message</span>
|
|
667
|
+
<span id="token-counter" style="font-size:0.78rem;color:var(--fg-faint);font-family:var(--font-mono);"></span>
|
|
668
|
+
</div>
|
|
669
|
+
<div class="compose-bar">
|
|
670
|
+
<textarea id="prompt-input" placeholder="Ask me anything... (Ctrl+Enter to send)" disabled rows="2"></textarea>
|
|
671
|
+
<button id="generate" class="primary" disabled style="height:auto;align-self:stretch;">Send</button>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div class="compose-actions">
|
|
676
|
+
<button id="stop" class="danger" disabled>Stop</button>
|
|
677
|
+
<button id="clear" disabled>Clear</button>
|
|
678
|
+
<button id="quick-test" disabled title="Quick test using embedded GGUF tokenizer">Quick test</button>
|
|
679
|
+
<button id="voice" disabled title="Voice mode: speak to the model and hear the reply (Web Speech API)">Voice</button>
|
|
680
|
+
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.82rem;color:var(--fg-faint);">
|
|
681
|
+
<input type="checkbox" id="voice-speak" checked>
|
|
682
|
+
Speak replies
|
|
683
|
+
</label>
|
|
684
|
+
<span style="flex:1;"></span>
|
|
685
|
+
<span class="stats" id="gen-stats"></span>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<!-- ==================== FOOTER ==================== -->
|
|
690
|
+
<div class="footer">
|
|
691
|
+
<a href="https://github.com/sauravpanda/flarellm">github.com/sauravpanda/flarellm</a>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
<script type="module">
|
|
695
|
+
import init, {
|
|
696
|
+
FlareEngine, FlareProgressiveLoader, FlareTokenizer,
|
|
697
|
+
webgpu_available, supports_webnn, supports_webtransport, init_thread_pool,
|
|
698
|
+
supports_speech_recognition, supports_speech_synthesis,
|
|
699
|
+
is_model_cached, cache_model, load_cached_model,
|
|
700
|
+
delete_cached_model, list_cached_models, storage_estimate
|
|
701
|
+
} from '../pkg/flare_web.js';
|
|
702
|
+
|
|
703
|
+
const $ = id => document.getElementById(id);
|
|
704
|
+
const $status = $('status');
|
|
705
|
+
const $env = $('env');
|
|
706
|
+
const $fileInput = $('file-input');
|
|
707
|
+
const $urlInput = $('url-input');
|
|
708
|
+
const $urlLoad = $('url-load');
|
|
709
|
+
const $progressWrap = $('progress-wrap');
|
|
710
|
+
const $progressBar = $('progress-bar');
|
|
711
|
+
const $progressText = $('progress-label-text');
|
|
712
|
+
const $progressSpeed = $('progress-label-speed');
|
|
713
|
+
const $modelSkeleton = $('model-skeleton');
|
|
714
|
+
const $modelInfoPanel = $('model-info-panel');
|
|
715
|
+
const $modelInfoGrid = $('model-info-grid');
|
|
716
|
+
const $tokBadge = $('tok-badge');
|
|
717
|
+
const $tokFileIn = $('tok-file-input');
|
|
718
|
+
const $tokUrlIn = $('tok-url-input');
|
|
719
|
+
const $tokUrlLoad = $('tok-url-load');
|
|
720
|
+
const $tokInfo = $('tok-info');
|
|
721
|
+
const $chatMode = $('chat-mode');
|
|
722
|
+
const $tmplBadge = $('template-badge');
|
|
723
|
+
const $systemRow = $('system-prompt-row');
|
|
724
|
+
const $systemIn = $('system-input');
|
|
725
|
+
const $promptIn = $('prompt-input');
|
|
726
|
+
const $tokenCounter = $('token-counter');
|
|
727
|
+
const $generate = $('generate');
|
|
728
|
+
const $stop = $('stop');
|
|
729
|
+
const $clear = $('clear');
|
|
730
|
+
const $quickTest = $('quick-test');
|
|
731
|
+
const $voice = $('voice');
|
|
732
|
+
const $voiceSpeak = $('voice-speak');
|
|
733
|
+
const $maxTokens = $('max-tokens');
|
|
734
|
+
const $chat = $('chat');
|
|
735
|
+
const $genStats = $('gen-stats');
|
|
736
|
+
|
|
737
|
+
// Performance dashboard elements
|
|
738
|
+
const $perfPanel = $('perf-panel');
|
|
739
|
+
const $perfTps = $('perf-tps');
|
|
740
|
+
const $perfTtft = $('perf-ttft');
|
|
741
|
+
const $perfTokens = $('perf-tokens');
|
|
742
|
+
const $perfMem = $('perf-mem');
|
|
743
|
+
const $perfBackend = $('perf-backend');
|
|
744
|
+
|
|
745
|
+
// Sampling param elements
|
|
746
|
+
const $paramTemp = $('param-temp');
|
|
747
|
+
const $paramTopp = $('param-topp');
|
|
748
|
+
const $paramTopk = $('param-topk');
|
|
749
|
+
const $paramRepeat = $('param-repeat');
|
|
750
|
+
|
|
751
|
+
let engine = null;
|
|
752
|
+
let tokenizer = null;
|
|
753
|
+
let streaming = false;
|
|
754
|
+
let opfsAvailable = false;
|
|
755
|
+
let backendName = 'CPU';
|
|
756
|
+
|
|
757
|
+
// Conversation history
|
|
758
|
+
let history = [];
|
|
759
|
+
|
|
760
|
+
// Rolling tokens-per-second tracker
|
|
761
|
+
let tpsWindow = []; // { time, count }
|
|
762
|
+
|
|
763
|
+
// ---------- Slider value display ----------
|
|
764
|
+
function setupSlider(slider, display, decimals = 2) {
|
|
765
|
+
const update = () => { display.textContent = parseFloat(slider.value).toFixed(decimals); };
|
|
766
|
+
slider.addEventListener('input', update);
|
|
767
|
+
update();
|
|
768
|
+
}
|
|
769
|
+
setupSlider($paramTemp, $('val-temp'));
|
|
770
|
+
setupSlider($paramTopp, $('val-topp'));
|
|
771
|
+
setupSlider($paramTopk, $('val-topk'), 0);
|
|
772
|
+
setupSlider($paramRepeat, $('val-repeat'));
|
|
773
|
+
setupSlider($maxTokens, $('val-maxtokens'), 0);
|
|
774
|
+
|
|
775
|
+
// ---------- Markdown rendering (lightweight) ----------
|
|
776
|
+
function renderMarkdown(text) {
|
|
777
|
+
// Simple markdown: code blocks, inline code, bold, italic, lists
|
|
778
|
+
let html = escapeHtml(text);
|
|
779
|
+
|
|
780
|
+
// Fenced code blocks: ```lang\n...\n```
|
|
781
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
782
|
+
return `<pre><code>${code.trimEnd()}</code></pre>`;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Inline code
|
|
786
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
787
|
+
|
|
788
|
+
// Bold
|
|
789
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
790
|
+
|
|
791
|
+
// Italic
|
|
792
|
+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
793
|
+
|
|
794
|
+
// Split into blocks by double newlines for paragraphs
|
|
795
|
+
const blocks = html.split(/\n\n+/);
|
|
796
|
+
const rendered = blocks.map(block => {
|
|
797
|
+
// Already a pre block?
|
|
798
|
+
if (block.startsWith('<pre>')) return block;
|
|
799
|
+
|
|
800
|
+
// Unordered list
|
|
801
|
+
const lines = block.split('\n');
|
|
802
|
+
if (lines.every(l => /^[\-\*]\s/.test(l) || l.trim() === '')) {
|
|
803
|
+
const items = lines.filter(l => l.trim()).map(l => `<li>${l.replace(/^[\-\*]\s/, '')}</li>`);
|
|
804
|
+
return `<ul>${items.join('')}</ul>`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Ordered list
|
|
808
|
+
if (lines.every(l => /^\d+\.\s/.test(l) || l.trim() === '')) {
|
|
809
|
+
const items = lines.filter(l => l.trim()).map(l => `<li>${l.replace(/^\d+\.\s/, '')}</li>`);
|
|
810
|
+
return `<ol>${items.join('')}</ol>`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Regular paragraph - join single newlines with <br>
|
|
814
|
+
return `<p>${block.replace(/\n/g, '<br>')}</p>`;
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
return rendered.join('');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function escapeHtml(str) {
|
|
821
|
+
return str
|
|
822
|
+
.replace(/&/g, '&')
|
|
823
|
+
.replace(/</g, '<')
|
|
824
|
+
.replace(/>/g, '>');
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---------- Template formatting ----------
|
|
828
|
+
function buildPrompt(hist, templateName) {
|
|
829
|
+
switch (templateName) {
|
|
830
|
+
case 'ChatML': {
|
|
831
|
+
let out = '';
|
|
832
|
+
for (const m of hist) {
|
|
833
|
+
out += `<|im_start|>${m.role}\n${m.content}<|im_end|>\n`;
|
|
834
|
+
}
|
|
835
|
+
out += '<|im_start|>assistant\n';
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
case 'Llama3': {
|
|
839
|
+
let out = '<|begin_of_text|>';
|
|
840
|
+
for (const m of hist) {
|
|
841
|
+
out += `<|start_header_id|>${m.role}<|end_header_id|>\n\n${m.content}<|eot_id|>`;
|
|
842
|
+
}
|
|
843
|
+
out += '<|start_header_id|>assistant<|end_header_id|>\n\n';
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
case 'Phi3': {
|
|
847
|
+
let out = '';
|
|
848
|
+
for (const m of hist) {
|
|
849
|
+
const tag = m.role === 'assistant' ? '<|assistant|>' :
|
|
850
|
+
m.role === 'system' ? '<|system|>' : '<|user|>';
|
|
851
|
+
out += `${tag}\n${m.content}<|end|>\n`;
|
|
852
|
+
}
|
|
853
|
+
out += '<|assistant|>\n';
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
case 'Gemma': {
|
|
857
|
+
let out = '';
|
|
858
|
+
for (const m of hist) {
|
|
859
|
+
const role = m.role === 'assistant' ? 'model' : 'user';
|
|
860
|
+
out += `<start_of_turn>${role}\n${m.content}<end_of_turn>\n`;
|
|
861
|
+
}
|
|
862
|
+
out += '<start_of_turn>model\n';
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
case 'Alpaca': {
|
|
866
|
+
const parts = [];
|
|
867
|
+
for (const m of hist) {
|
|
868
|
+
if (m.role === 'user') parts.push(`### Instruction:\n${m.content}`);
|
|
869
|
+
else if (m.role === 'assistant') parts.push(`### Response:\n${m.content}`);
|
|
870
|
+
}
|
|
871
|
+
parts.push('### Response:');
|
|
872
|
+
return parts.join('\n\n');
|
|
873
|
+
}
|
|
874
|
+
default:
|
|
875
|
+
return hist.filter(m => m.role !== 'system').map(m => m.content).join('\n');
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ---------- Chat UI helpers ----------
|
|
880
|
+
function appendBubble(role, content, isStreaming = false) {
|
|
881
|
+
const wrap = document.createElement('div');
|
|
882
|
+
wrap.className = 'msg-wrap';
|
|
883
|
+
const label = document.createElement('div');
|
|
884
|
+
label.className = 'msg-role';
|
|
885
|
+
label.textContent = role === 'user' ? 'You' : 'Assistant';
|
|
886
|
+
const bubble = document.createElement('div');
|
|
887
|
+
bubble.className = `msg ${role}` + (isStreaming ? ' streaming' : '');
|
|
888
|
+
if (role === 'user') {
|
|
889
|
+
bubble.textContent = content;
|
|
890
|
+
} else {
|
|
891
|
+
bubble.innerHTML = content ? renderMarkdown(content) : '';
|
|
892
|
+
}
|
|
893
|
+
wrap.appendChild(label);
|
|
894
|
+
wrap.appendChild(bubble);
|
|
895
|
+
$chat.appendChild(wrap);
|
|
896
|
+
$chat.scrollTop = $chat.scrollHeight;
|
|
897
|
+
return bubble;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function updateAssistantBubble(bubble, text) {
|
|
901
|
+
// During streaming, just use textContent for performance;
|
|
902
|
+
// render markdown only on completion
|
|
903
|
+
bubble.textContent = text;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function finalizeAssistantBubble(bubble, text) {
|
|
907
|
+
bubble.innerHTML = renderMarkdown(text);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function clearChat() {
|
|
911
|
+
$chat.innerHTML = '';
|
|
912
|
+
history = [];
|
|
913
|
+
if (engine) engine.reset();
|
|
914
|
+
$genStats.textContent = '';
|
|
915
|
+
resetPerfDashboard();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---------- Performance dashboard ----------
|
|
919
|
+
function showPerfPanel() {
|
|
920
|
+
$perfPanel.style.display = '';
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function resetPerfDashboard() {
|
|
924
|
+
$perfTps.textContent = '--';
|
|
925
|
+
$perfTtft.textContent = '--';
|
|
926
|
+
$perfTokens.textContent = '0';
|
|
927
|
+
tpsWindow = [];
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function updatePerfLive(tokenCount, prefillMs) {
|
|
931
|
+
const now = performance.now();
|
|
932
|
+
tpsWindow.push({ time: now, count: tokenCount });
|
|
933
|
+
// Keep last 2 seconds of data for rolling average
|
|
934
|
+
const cutoff = now - 2000;
|
|
935
|
+
tpsWindow = tpsWindow.filter(e => e.time > cutoff);
|
|
936
|
+
|
|
937
|
+
if (tpsWindow.length >= 2) {
|
|
938
|
+
const first = tpsWindow[0];
|
|
939
|
+
const last = tpsWindow[tpsWindow.length - 1];
|
|
940
|
+
const dt = (last.time - first.time) / 1000;
|
|
941
|
+
const dc = last.count - first.count;
|
|
942
|
+
if (dt > 0 && dc > 0) {
|
|
943
|
+
$perfTps.textContent = (dc / dt).toFixed(1);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
$perfTtft.textContent = prefillMs.toFixed(0);
|
|
948
|
+
$perfTokens.textContent = String(tokenCount);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function finalizePerfDashboard(summary) {
|
|
952
|
+
if (summary) {
|
|
953
|
+
$perfTps.textContent = summary.tokens_per_second.toFixed(1);
|
|
954
|
+
$perfTtft.textContent = summary.prefill_ms.toFixed(0);
|
|
955
|
+
$perfTokens.textContent = String(summary.tokens_generated);
|
|
956
|
+
}
|
|
957
|
+
// Memory estimate
|
|
958
|
+
if (performance.memory) {
|
|
959
|
+
$perfMem.textContent = (performance.memory.usedJSHeapSize / 1e6).toFixed(0);
|
|
960
|
+
} else {
|
|
961
|
+
$perfMem.textContent = 'N/A';
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ---------- OPFS cache UI ----------
|
|
966
|
+
function formatSize(bytes) {
|
|
967
|
+
if (bytes < 1024) return bytes + ' B';
|
|
968
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
969
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
970
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function refreshCacheList() {
|
|
974
|
+
const $cachePanel = $('cache-panel');
|
|
975
|
+
const $cacheList = $('cache-list');
|
|
976
|
+
const $cacheCount = $('cache-count');
|
|
977
|
+
const $cacheStats = $('cache-stats');
|
|
978
|
+
if (!opfsAvailable || !$cachePanel) {
|
|
979
|
+
if ($cachePanel) $cachePanel.style.display = 'none';
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
const raw = await list_cached_models();
|
|
984
|
+
const models = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
985
|
+
$cachePanel.style.display = '';
|
|
986
|
+
$cacheCount.textContent = models.length;
|
|
987
|
+
$cacheList.innerHTML = '';
|
|
988
|
+
if (models.length === 0) {
|
|
989
|
+
$cacheList.innerHTML = '<li style="opacity:0.5;font-size:0.85rem;padding:0.3rem 0;">No cached models yet. Models loaded from URL or file will be cached here.</li>';
|
|
990
|
+
} else {
|
|
991
|
+
for (const m of models) {
|
|
992
|
+
const li = document.createElement('li');
|
|
993
|
+
li.className = 'cache-item';
|
|
994
|
+
li.innerHTML = `
|
|
995
|
+
<span class="cache-item-name">${m.name}</span>
|
|
996
|
+
<span class="cache-item-size">${formatSize(m.size)}</span>
|
|
997
|
+
<span class="cache-item-actions">
|
|
998
|
+
<button class="cache-load" data-name="${m.name}">Load</button>
|
|
999
|
+
<button class="cache-delete" data-name="${m.name}">Delete</button>
|
|
1000
|
+
</span>`;
|
|
1001
|
+
$cacheList.appendChild(li);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const est = await storage_estimate();
|
|
1005
|
+
const info = JSON.parse(typeof est === 'string' ? est : est.toString());
|
|
1006
|
+
if (info.usage !== undefined) {
|
|
1007
|
+
$cacheStats.textContent = `Storage: ${formatSize(info.usage)} used / ${formatSize(info.quota)} quota`;
|
|
1008
|
+
}
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
console.warn('Failed to list cached models:', e);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Handle clicks on cache list buttons (event delegation)
|
|
1015
|
+
document.getElementById('cache-list').addEventListener('click', async (e) => {
|
|
1016
|
+
const btn = e.target.closest('button');
|
|
1017
|
+
if (!btn) return;
|
|
1018
|
+
const name = btn.dataset.name;
|
|
1019
|
+
|
|
1020
|
+
if (btn.classList.contains('cache-load')) {
|
|
1021
|
+
btn.disabled = true;
|
|
1022
|
+
showProgress('Loading from OPFS cache...');
|
|
1023
|
+
try {
|
|
1024
|
+
const t0 = performance.now();
|
|
1025
|
+
clearChat();
|
|
1026
|
+
const cached = await load_cached_model(name);
|
|
1027
|
+
if (cached) {
|
|
1028
|
+
const bytes = new Uint8Array(cached.buffer || cached);
|
|
1029
|
+
updateProgress(bytes.length, bytes.length, 'Parsing (cached)...');
|
|
1030
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1031
|
+
const eng = FlareEngine.load(bytes);
|
|
1032
|
+
await onModelLoaded(eng, performance.now() - t0, bytes);
|
|
1033
|
+
$status.textContent = 'Loaded from OPFS cache.';
|
|
1034
|
+
} else {
|
|
1035
|
+
$status.textContent = 'Cache miss: model not found.';
|
|
1036
|
+
hideProgress();
|
|
1037
|
+
}
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
$status.textContent = 'Cache load error: ' + err;
|
|
1040
|
+
$status.classList.add('error');
|
|
1041
|
+
hideProgress();
|
|
1042
|
+
} finally { btn.disabled = false; }
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (btn.classList.contains('cache-delete')) {
|
|
1046
|
+
if (!confirm(`Delete cached model "${name}"?`)) return;
|
|
1047
|
+
btn.disabled = true;
|
|
1048
|
+
try {
|
|
1049
|
+
await delete_cached_model(name);
|
|
1050
|
+
await refreshCacheList();
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
console.warn('Delete failed:', err);
|
|
1053
|
+
} finally { btn.disabled = false; }
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// ---------- Show/hide system prompt row ----------
|
|
1058
|
+
$chatMode.addEventListener('change', () => {
|
|
1059
|
+
$systemRow.style.display = $chatMode.checked ? '' : 'none';
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// ---------- Tab switching ----------
|
|
1063
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
1064
|
+
btn.addEventListener('click', () => {
|
|
1065
|
+
const panel = btn.closest('.panel');
|
|
1066
|
+
panel.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
1067
|
+
panel.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
1068
|
+
btn.classList.add('active');
|
|
1069
|
+
panel.querySelector('#tab-' + btn.dataset.tab).classList.add('active');
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ---------- Progress bar ----------
|
|
1074
|
+
let downloadStartTime = 0;
|
|
1075
|
+
let lastLoadedBytes = 0;
|
|
1076
|
+
|
|
1077
|
+
function showProgress(label) {
|
|
1078
|
+
$progressWrap.classList.add('visible');
|
|
1079
|
+
$progressBar.style.width = '0%';
|
|
1080
|
+
$progressBar.classList.remove('indeterminate');
|
|
1081
|
+
$progressText.textContent = label;
|
|
1082
|
+
$progressSpeed.textContent = '';
|
|
1083
|
+
$modelSkeleton.classList.add('visible');
|
|
1084
|
+
downloadStartTime = performance.now();
|
|
1085
|
+
lastLoadedBytes = 0;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function updateProgress(loaded, total, label) {
|
|
1089
|
+
if (total > 0) {
|
|
1090
|
+
const pct = Math.round(loaded / total * 100);
|
|
1091
|
+
$progressBar.style.width = `${pct}%`;
|
|
1092
|
+
$progressBar.classList.remove('indeterminate');
|
|
1093
|
+
const mb = (loaded / 1e6).toFixed(1);
|
|
1094
|
+
$progressText.textContent = `${label} ${mb} / ${(total/1e6).toFixed(1)} MB (${pct}%)`;
|
|
1095
|
+
} else {
|
|
1096
|
+
$progressBar.classList.add('indeterminate');
|
|
1097
|
+
$progressText.textContent = `${label} ${(loaded/1e6).toFixed(1)} MB...`;
|
|
1098
|
+
}
|
|
1099
|
+
// Download speed
|
|
1100
|
+
const elapsed = (performance.now() - downloadStartTime) / 1000;
|
|
1101
|
+
if (elapsed > 0.5 && loaded > 0) {
|
|
1102
|
+
const speed = loaded / elapsed / 1e6;
|
|
1103
|
+
$progressSpeed.textContent = `${speed.toFixed(1)} MB/s`;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function hideProgress() {
|
|
1108
|
+
setTimeout(() => {
|
|
1109
|
+
$progressWrap.classList.remove('visible');
|
|
1110
|
+
$modelSkeleton.classList.remove('visible');
|
|
1111
|
+
}, 400);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ---------- Model info panel ----------
|
|
1115
|
+
function showModelInfo(eng) {
|
|
1116
|
+
$modelInfoPanel.style.display = '';
|
|
1117
|
+
const grid = $modelInfoGrid;
|
|
1118
|
+
grid.innerHTML = '';
|
|
1119
|
+
|
|
1120
|
+
// Parse metadata for extra info
|
|
1121
|
+
let meta = {};
|
|
1122
|
+
try { meta = JSON.parse(eng.metadata_json); } catch (_) {}
|
|
1123
|
+
|
|
1124
|
+
const arch = eng.architecture || 'unknown';
|
|
1125
|
+
const name = eng.model_name || arch;
|
|
1126
|
+
const layers = eng.num_layers;
|
|
1127
|
+
const heads = eng.num_heads;
|
|
1128
|
+
const dim = eng.hidden_dim;
|
|
1129
|
+
const ctx = eng.max_seq_len;
|
|
1130
|
+
const vocab = eng.vocab_size;
|
|
1131
|
+
|
|
1132
|
+
// Rough parameter count estimate: vocab*dim + layers*(4*dim*dim + 3*dim*dim + 2*dim) + dim
|
|
1133
|
+
// This is approximate for transformer models
|
|
1134
|
+
const paramEstimate = vocab * dim + layers * (4 * dim * dim + 3 * dim * dim + 2 * dim) + dim;
|
|
1135
|
+
const paramStr = paramEstimate > 1e9
|
|
1136
|
+
? (paramEstimate / 1e9).toFixed(1) + 'B'
|
|
1137
|
+
: (paramEstimate / 1e6).toFixed(0) + 'M';
|
|
1138
|
+
|
|
1139
|
+
// Quantization from metadata
|
|
1140
|
+
const quantType = meta['general.file_type'] || meta['general.quantization_version'] || '--';
|
|
1141
|
+
|
|
1142
|
+
const fields = [
|
|
1143
|
+
['Name', name],
|
|
1144
|
+
['Architecture', arch.charAt(0).toUpperCase() + arch.slice(1)],
|
|
1145
|
+
['Parameters', '~' + paramStr],
|
|
1146
|
+
['Quantization', String(quantType)],
|
|
1147
|
+
['Context Window', ctx.toLocaleString()],
|
|
1148
|
+
['Layers', layers],
|
|
1149
|
+
['Heads', heads],
|
|
1150
|
+
['Hidden Dim', dim.toLocaleString()],
|
|
1151
|
+
['Vocab Size', vocab.toLocaleString()],
|
|
1152
|
+
['Template', eng.chat_template_name],
|
|
1153
|
+
];
|
|
1154
|
+
|
|
1155
|
+
for (const [label, value] of fields) {
|
|
1156
|
+
const dt = document.createElement('dt');
|
|
1157
|
+
dt.textContent = label;
|
|
1158
|
+
const dd = document.createElement('dd');
|
|
1159
|
+
dd.textContent = value;
|
|
1160
|
+
grid.appendChild(dt);
|
|
1161
|
+
grid.appendChild(dd);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ---------- Model loaded ----------
|
|
1166
|
+
async function onModelLoaded(eng, ms, rawBytes) {
|
|
1167
|
+
engine = eng;
|
|
1168
|
+
const tmplName = eng.chat_template_name;
|
|
1169
|
+
|
|
1170
|
+
showModelInfo(eng);
|
|
1171
|
+
|
|
1172
|
+
$generate.disabled = false;
|
|
1173
|
+
$promptIn.disabled = false;
|
|
1174
|
+
$systemIn.disabled = false;
|
|
1175
|
+
$clear.disabled = false;
|
|
1176
|
+
$quickTest.disabled = false;
|
|
1177
|
+
// Voice button is only useful once both a model and tokenizer are ready
|
|
1178
|
+
// and the browser exposes SpeechRecognition. The actual enable check is
|
|
1179
|
+
// performed in refreshVoiceButton() (called from onTokenizerLoaded too).
|
|
1180
|
+
refreshVoiceButton();
|
|
1181
|
+
$tmplBadge.textContent = tmplName;
|
|
1182
|
+
$tmplBadge.style.display = '';
|
|
1183
|
+
hideProgress();
|
|
1184
|
+
|
|
1185
|
+
// Show perf panel
|
|
1186
|
+
showPerfPanel();
|
|
1187
|
+
$perfBackend.textContent = 'CPU';
|
|
1188
|
+
backendName = 'CPU';
|
|
1189
|
+
|
|
1190
|
+
$status.textContent = `Model loaded in ${(ms/1000).toFixed(1)}s. Ready.`;
|
|
1191
|
+
|
|
1192
|
+
// Try to activate WebGPU backend
|
|
1193
|
+
if (webgpu_available()) {
|
|
1194
|
+
$status.textContent = 'Initializing WebGPU...';
|
|
1195
|
+
try {
|
|
1196
|
+
const gpuOn = await eng.init_gpu();
|
|
1197
|
+
if (gpuOn) {
|
|
1198
|
+
if (rawBytes) {
|
|
1199
|
+
try {
|
|
1200
|
+
const rawOk = eng.load_raw_weights(rawBytes);
|
|
1201
|
+
backendName = rawOk ? 'WebGPU (fused)' : 'WebGPU (f32)';
|
|
1202
|
+
} catch (_) {
|
|
1203
|
+
backendName = 'WebGPU (f32)';
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
backendName = 'WebGPU';
|
|
1207
|
+
}
|
|
1208
|
+
$perfBackend.textContent = backendName;
|
|
1209
|
+
$status.textContent = `Model loaded in ${(ms/1000).toFixed(1)}s. ${backendName} active.`;
|
|
1210
|
+
} else {
|
|
1211
|
+
$status.textContent = `Model loaded in ${(ms/1000).toFixed(1)}s. WebGPU init failed, using CPU.`;
|
|
1212
|
+
}
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
$status.textContent = `Model loaded in ${(ms/1000).toFixed(1)}s. WebGPU error, using CPU.`;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Memory estimate
|
|
1219
|
+
if (performance.memory) {
|
|
1220
|
+
$perfMem.textContent = (performance.memory.usedJSHeapSize / 1e6).toFixed(0);
|
|
1221
|
+
}
|
|
1222
|
+
$env.textContent = `Backend: ${backendName}`;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function onTokenizerLoaded(tok) {
|
|
1226
|
+
tokenizer = tok;
|
|
1227
|
+
$tokBadge.textContent = 'loaded';
|
|
1228
|
+
$tokBadge.className = 'badge ok';
|
|
1229
|
+
$tokInfo.textContent =
|
|
1230
|
+
`Vocab: ${tok.vocab_size} | ` +
|
|
1231
|
+
`BOS: ${tok.bos_token_id ?? 'none'} | ` +
|
|
1232
|
+
`EOS: ${tok.eos_token_id ?? 'none'}`;
|
|
1233
|
+
refreshVoiceButton();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ---------- Voice mode (Web Speech API) ----------
|
|
1237
|
+
//
|
|
1238
|
+
// Foundation for the voice pipeline (issue #395). For now we rely on the
|
|
1239
|
+
// browser-provided Web Speech API: SpeechRecognition for STT and
|
|
1240
|
+
// SpeechSynthesis for TTS. These run entirely in the browser (Chrome
|
|
1241
|
+
// routes STT through a cloud service, Safari uses on-device), so this is
|
|
1242
|
+
// a pragmatic bootstrap rather than a fully offline pipeline.
|
|
1243
|
+
//
|
|
1244
|
+
// NEXT STEP (true offline voice): integrate a Whisper-tiny model for
|
|
1245
|
+
// speech-to-text and a small neural TTS (e.g. Piper/VITS) for synthesis,
|
|
1246
|
+
// capturing microphone audio through an AudioWorklet so the main thread
|
|
1247
|
+
// stays responsive. That work replaces this class while keeping the same
|
|
1248
|
+
// listen()/speak() interface.
|
|
1249
|
+
class VoiceMode {
|
|
1250
|
+
constructor() {
|
|
1251
|
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1252
|
+
if (SR) {
|
|
1253
|
+
this.recognition = new SR();
|
|
1254
|
+
this.recognition.continuous = false;
|
|
1255
|
+
this.recognition.interimResults = false;
|
|
1256
|
+
this.recognition.lang = navigator.language || 'en-US';
|
|
1257
|
+
}
|
|
1258
|
+
this.synth = window.speechSynthesis || null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
get supported() {
|
|
1262
|
+
return !!this.recognition;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
listen() {
|
|
1266
|
+
return new Promise((resolve, reject) => {
|
|
1267
|
+
if (!this.recognition) {
|
|
1268
|
+
reject(new Error('SpeechRecognition not supported in this browser'));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
let settled = false;
|
|
1272
|
+
this.recognition.onresult = (event) => {
|
|
1273
|
+
settled = true;
|
|
1274
|
+
const text = event.results[0][0].transcript;
|
|
1275
|
+
resolve(text);
|
|
1276
|
+
};
|
|
1277
|
+
this.recognition.onerror = (event) => {
|
|
1278
|
+
if (!settled) reject(event.error || new Error('recognition error'));
|
|
1279
|
+
};
|
|
1280
|
+
this.recognition.onend = () => {
|
|
1281
|
+
if (!settled) reject(new Error('no speech detected'));
|
|
1282
|
+
};
|
|
1283
|
+
try {
|
|
1284
|
+
this.recognition.start();
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
reject(err);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
speak(text) {
|
|
1292
|
+
if (!this.synth || !text) return;
|
|
1293
|
+
try {
|
|
1294
|
+
// Cancel any previous utterance so replies don't queue up.
|
|
1295
|
+
this.synth.cancel();
|
|
1296
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
1297
|
+
utterance.lang = navigator.language || 'en-US';
|
|
1298
|
+
this.synth.speak(utterance);
|
|
1299
|
+
} catch (_) { /* best effort */ }
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const voiceMode = new VoiceMode();
|
|
1304
|
+
|
|
1305
|
+
function refreshVoiceButton() {
|
|
1306
|
+
if (!$voice) return;
|
|
1307
|
+
const ready = engine && tokenizer && voiceMode.supported && !streaming;
|
|
1308
|
+
$voice.disabled = !ready;
|
|
1309
|
+
if (!voiceMode.supported) {
|
|
1310
|
+
$voice.title = 'Voice mode unavailable: this browser does not expose SpeechRecognition';
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// ---------- WASM init ----------
|
|
1315
|
+
async function startWasm() {
|
|
1316
|
+
try {
|
|
1317
|
+
await init();
|
|
1318
|
+
|
|
1319
|
+
let threadsInfo = '';
|
|
1320
|
+
if (typeof init_thread_pool === 'function' && typeof SharedArrayBuffer !== 'undefined') {
|
|
1321
|
+
const numThreads = navigator.hardwareConcurrency || 4;
|
|
1322
|
+
try {
|
|
1323
|
+
await init_thread_pool(numThreads);
|
|
1324
|
+
threadsInfo = ` | ${numThreads} threads`;
|
|
1325
|
+
} catch (e) {
|
|
1326
|
+
console.warn('Thread pool init failed (COOP/COEP headers may be missing):', e);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Detect OPFS support
|
|
1331
|
+
try {
|
|
1332
|
+
if (navigator.storage && navigator.storage.getDirectory) {
|
|
1333
|
+
await navigator.storage.getDirectory();
|
|
1334
|
+
opfsAvailable = true;
|
|
1335
|
+
}
|
|
1336
|
+
} catch (_) { opfsAvailable = false; }
|
|
1337
|
+
|
|
1338
|
+
$status.textContent = 'WASM module loaded. Load a model to begin.';
|
|
1339
|
+
const webnnAvailable = supports_webnn();
|
|
1340
|
+
const webtransportAvailable = supports_webtransport();
|
|
1341
|
+
$env.textContent = `WebGPU: ${webgpu_available() ? 'available' : 'not available'} | WebNN: ${webnnAvailable ? 'available' : 'not available'} | WebTransport: ${webtransportAvailable ? 'available' : 'not available'}${threadsInfo} | OPFS: ${opfsAvailable ? 'yes' : 'no'}`;
|
|
1342
|
+
[$fileInput, $urlInput, $urlLoad, $tokFileIn, $tokUrlIn, $tokUrlLoad]
|
|
1343
|
+
.forEach(el => { el.disabled = false; });
|
|
1344
|
+
|
|
1345
|
+
// Populate OPFS cache list
|
|
1346
|
+
refreshCacheList();
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
$status.textContent = 'Failed to load WASM: ' + err;
|
|
1349
|
+
$status.classList.add('error');
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ---------- Model loaders ----------
|
|
1354
|
+
$fileInput.addEventListener('change', async e => {
|
|
1355
|
+
const file = e.target.files[0];
|
|
1356
|
+
if (!file) return;
|
|
1357
|
+
showProgress('Reading...');
|
|
1358
|
+
$generate.disabled = true;
|
|
1359
|
+
try {
|
|
1360
|
+
const buffer = await file.arrayBuffer();
|
|
1361
|
+
updateProgress(file.size, file.size, 'Parsing...');
|
|
1362
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1363
|
+
const t0 = performance.now();
|
|
1364
|
+
clearChat();
|
|
1365
|
+
const bytes = new Uint8Array(buffer);
|
|
1366
|
+
await onModelLoaded(FlareEngine.load(bytes), performance.now() - t0, bytes);
|
|
1367
|
+
// Cache to OPFS for fast reload on next visit (best-effort)
|
|
1368
|
+
if (opfsAvailable) {
|
|
1369
|
+
cache_model(file.name, bytes).then(() => refreshCacheList()).catch(() => {});
|
|
1370
|
+
}
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
$status.textContent = 'Error loading model: ' + err;
|
|
1373
|
+
$status.classList.add('error');
|
|
1374
|
+
hideProgress();
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
const MODEL_CACHE = 'flare-models-v1';
|
|
1379
|
+
|
|
1380
|
+
function modelNameFromUrl(url) {
|
|
1381
|
+
try {
|
|
1382
|
+
const pathname = new URL(url).pathname;
|
|
1383
|
+
return pathname.split('/').pop() || 'model.gguf';
|
|
1384
|
+
} catch (_) {
|
|
1385
|
+
return url.split('/').pop() || 'model.gguf';
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function loadModelFromUrl(url) {
|
|
1390
|
+
const modelName = modelNameFromUrl(url);
|
|
1391
|
+
|
|
1392
|
+
// 1. Check OPFS cache first (faster than Cache API for large files).
|
|
1393
|
+
if (opfsAvailable) {
|
|
1394
|
+
try {
|
|
1395
|
+
const isCached = await is_model_cached(modelName);
|
|
1396
|
+
if (isCached) {
|
|
1397
|
+
updateProgress(1, 1, 'Loading from OPFS cache...');
|
|
1398
|
+
$progressSpeed.textContent = 'cached (OPFS)';
|
|
1399
|
+
const cached = await load_cached_model(modelName);
|
|
1400
|
+
if (cached) {
|
|
1401
|
+
const buf = new Uint8Array(cached.buffer || cached);
|
|
1402
|
+
return { bytes: buf, fromCache: true };
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
} catch (_) { /* OPFS unavailable - fall through */ }
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// 2. Fall back to Cache API for previously downloaded copies.
|
|
1409
|
+
if ('caches' in window) {
|
|
1410
|
+
try {
|
|
1411
|
+
const cache = await caches.open(MODEL_CACHE);
|
|
1412
|
+
const cached = await cache.match(url);
|
|
1413
|
+
if (cached) {
|
|
1414
|
+
updateProgress(1, 1, 'Loading from cache...');
|
|
1415
|
+
$progressSpeed.textContent = 'cached';
|
|
1416
|
+
const buf = new Uint8Array(await cached.arrayBuffer());
|
|
1417
|
+
// Migrate to OPFS for faster access next time
|
|
1418
|
+
if (opfsAvailable) {
|
|
1419
|
+
cache_model(modelName, buf).catch(() => {});
|
|
1420
|
+
}
|
|
1421
|
+
return { bytes: buf, fromCache: true };
|
|
1422
|
+
}
|
|
1423
|
+
} catch (_) { /* Cache API unavailable */ }
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// 3. Fresh download with progress
|
|
1427
|
+
const resp = await fetch(url);
|
|
1428
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
1429
|
+
const total = parseInt(resp.headers.get('Content-Length') || '0', 10);
|
|
1430
|
+
const reader = resp.body.getReader();
|
|
1431
|
+
const chunks = [];
|
|
1432
|
+
let loaded = 0;
|
|
1433
|
+
while (true) {
|
|
1434
|
+
const { done, value } = await reader.read();
|
|
1435
|
+
if (done) break;
|
|
1436
|
+
chunks.push(value);
|
|
1437
|
+
loaded += value.byteLength;
|
|
1438
|
+
updateProgress(loaded, total || loaded, 'Downloading...');
|
|
1439
|
+
}
|
|
1440
|
+
const buf = new Uint8Array(loaded);
|
|
1441
|
+
let offset = 0;
|
|
1442
|
+
for (const chunk of chunks) { buf.set(chunk, offset); offset += chunk.byteLength; }
|
|
1443
|
+
|
|
1444
|
+
// 4. Store in OPFS for fast access on next visit (best-effort).
|
|
1445
|
+
if (opfsAvailable) {
|
|
1446
|
+
try {
|
|
1447
|
+
updateProgress(loaded, loaded, 'Caching to OPFS...');
|
|
1448
|
+
await cache_model(modelName, buf);
|
|
1449
|
+
refreshCacheList();
|
|
1450
|
+
} catch (_) { /* Quota exceeded or private browsing */ }
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// 5. Also store in Cache API as fallback (best-effort).
|
|
1454
|
+
if ('caches' in window) {
|
|
1455
|
+
try {
|
|
1456
|
+
const cache = await caches.open(MODEL_CACHE);
|
|
1457
|
+
await cache.put(url, new Response(buf, {
|
|
1458
|
+
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(loaded) }
|
|
1459
|
+
}));
|
|
1460
|
+
} catch (_) { /* Quota exceeded or private browsing */ }
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
return { bytes: buf, fromCache: false };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
$urlLoad.addEventListener('click', async () => {
|
|
1467
|
+
const url = $urlInput.value.trim();
|
|
1468
|
+
if (!url) return;
|
|
1469
|
+
showProgress('Connecting...');
|
|
1470
|
+
$generate.disabled = true;
|
|
1471
|
+
$urlLoad.disabled = true;
|
|
1472
|
+
try {
|
|
1473
|
+
const t0 = performance.now();
|
|
1474
|
+
clearChat();
|
|
1475
|
+
const { bytes, fromCache } = await loadModelFromUrl(url);
|
|
1476
|
+
updateProgress(bytes.length, bytes.length, fromCache ? 'Parsing (cached)...' : 'Parsing...');
|
|
1477
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1478
|
+
const eng = FlareEngine.load(bytes);
|
|
1479
|
+
await onModelLoaded(eng, performance.now() - t0, bytes);
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
$status.textContent = 'Error: ' + err;
|
|
1482
|
+
$status.classList.add('error');
|
|
1483
|
+
hideProgress();
|
|
1484
|
+
} finally { $urlLoad.disabled = false; }
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// ---------- Tokenizer loaders ----------
|
|
1488
|
+
$tokFileIn.addEventListener('change', async e => {
|
|
1489
|
+
const file = e.target.files[0];
|
|
1490
|
+
if (!file) return;
|
|
1491
|
+
try {
|
|
1492
|
+
onTokenizerLoaded(FlareTokenizer.from_json(await file.text()));
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
$tokInfo.textContent = 'Error: ' + err;
|
|
1495
|
+
$tokInfo.classList.add('error');
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
$tokUrlLoad.addEventListener('click', async () => {
|
|
1500
|
+
const url = $tokUrlIn.value.trim();
|
|
1501
|
+
if (!url) return;
|
|
1502
|
+
$tokUrlLoad.disabled = true;
|
|
1503
|
+
$tokInfo.textContent = 'Fetching...';
|
|
1504
|
+
try {
|
|
1505
|
+
const resp = await fetch(url);
|
|
1506
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
1507
|
+
onTokenizerLoaded(FlareTokenizer.from_json(await resp.text()));
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
$tokInfo.textContent = 'Error: ' + err;
|
|
1510
|
+
$tokInfo.classList.add('error');
|
|
1511
|
+
} finally { $tokUrlLoad.disabled = false; }
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
// ---------- Voice mode ----------
|
|
1515
|
+
// When true, the next completed generation will be spoken aloud through
|
|
1516
|
+
// window.speechSynthesis. Set by the Voice button click handler and
|
|
1517
|
+
// cleared in finishStream().
|
|
1518
|
+
let voiceAutoSpeak = false;
|
|
1519
|
+
|
|
1520
|
+
$voice.addEventListener('click', async () => {
|
|
1521
|
+
if (!engine || streaming) return;
|
|
1522
|
+
if (!voiceMode.supported) {
|
|
1523
|
+
$genStats.textContent = 'Voice mode unavailable in this browser.';
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const prevLabel = $voice.textContent;
|
|
1527
|
+
$voice.disabled = true;
|
|
1528
|
+
$voice.textContent = 'Listening...';
|
|
1529
|
+
$genStats.textContent = 'Listening for speech...';
|
|
1530
|
+
try {
|
|
1531
|
+
const text = await voiceMode.listen();
|
|
1532
|
+
if (!text) {
|
|
1533
|
+
$genStats.textContent = 'No speech detected.';
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
$promptIn.value = text;
|
|
1537
|
+
voiceAutoSpeak = $voiceSpeak.checked && supports_speech_synthesis();
|
|
1538
|
+
// Trigger the normal generate path so voice uses the same sampling,
|
|
1539
|
+
// chat template, history, and streaming UI as typed messages.
|
|
1540
|
+
$generate.click();
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
$genStats.textContent = 'Voice error: ' + (err && err.message ? err.message : err);
|
|
1543
|
+
} finally {
|
|
1544
|
+
$voice.textContent = prevLabel;
|
|
1545
|
+
refreshVoiceButton();
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
// ---------- Clear conversation ----------
|
|
1550
|
+
$clear.addEventListener('click', () => clearChat());
|
|
1551
|
+
|
|
1552
|
+
// ---------- Quick test ----------
|
|
1553
|
+
$quickTest.addEventListener('click', () => {
|
|
1554
|
+
if (!engine || streaming) return;
|
|
1555
|
+
const prompt = $promptIn.value.trim() || 'Hello';
|
|
1556
|
+
const maxToks = parseInt($maxTokens.value, 10) || 64;
|
|
1557
|
+
|
|
1558
|
+
const testEncode = engine.encode_text(prompt);
|
|
1559
|
+
if (!testEncode || testEncode.length === 0) {
|
|
1560
|
+
$genStats.textContent = 'Quick test unavailable: no embedded GGUF tokenizer.';
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
streaming = true;
|
|
1565
|
+
$generate.disabled = true;
|
|
1566
|
+
$stop.disabled = false;
|
|
1567
|
+
$promptIn.value = '';
|
|
1568
|
+
$tokenCounter.textContent = '';
|
|
1569
|
+
$genStats.textContent = 'Generating...';
|
|
1570
|
+
tpsWindow = [];
|
|
1571
|
+
|
|
1572
|
+
appendBubble('user', prompt);
|
|
1573
|
+
history.push({ role: 'user', content: prompt });
|
|
1574
|
+
|
|
1575
|
+
engine.reset();
|
|
1576
|
+
engine.begin_stream(testEncode, maxToks);
|
|
1577
|
+
const prefillMs = engine.last_prefill_ms;
|
|
1578
|
+
|
|
1579
|
+
let count = 0;
|
|
1580
|
+
let assistantText = '';
|
|
1581
|
+
const bubble = appendBubble('assistant', '', true);
|
|
1582
|
+
showPerfPanel();
|
|
1583
|
+
|
|
1584
|
+
function quickTick() {
|
|
1585
|
+
if (!streaming) { finishQuick(); return; }
|
|
1586
|
+
const id = engine.next_token();
|
|
1587
|
+
if (id === undefined) { streaming = false; finishQuick(); return; }
|
|
1588
|
+
count++;
|
|
1589
|
+
assistantText += engine.decode_ids(new Uint32Array([id]));
|
|
1590
|
+
updateAssistantBubble(bubble, assistantText);
|
|
1591
|
+
$chat.scrollTop = $chat.scrollHeight;
|
|
1592
|
+
updatePerfLive(count, prefillMs);
|
|
1593
|
+
requestAnimationFrame(quickTick);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function finishQuick() {
|
|
1597
|
+
bubble.classList.remove('streaming');
|
|
1598
|
+
finalizeAssistantBubble(bubble, assistantText);
|
|
1599
|
+
if (assistantText) history.push({ role: 'assistant', content: assistantText });
|
|
1600
|
+
try {
|
|
1601
|
+
const s = JSON.parse(engine.performance_summary());
|
|
1602
|
+
finalizePerfDashboard(s);
|
|
1603
|
+
$genStats.textContent = `${s.tokens_generated} tokens | ${s.tokens_per_second.toFixed(1)} tok/s (embedded tokenizer)`;
|
|
1604
|
+
} catch (_) {
|
|
1605
|
+
$genStats.textContent = `${count} tokens generated`;
|
|
1606
|
+
}
|
|
1607
|
+
$generate.disabled = false;
|
|
1608
|
+
$stop.disabled = true;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
requestAnimationFrame(quickTick);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// ---------- Stop ----------
|
|
1615
|
+
$stop.addEventListener('click', () => {
|
|
1616
|
+
if (engine && streaming) engine.stop_stream();
|
|
1617
|
+
streaming = false;
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
// ---------- Generate (multi-turn streaming) ----------
|
|
1621
|
+
$generate.addEventListener('click', async () => {
|
|
1622
|
+
if (!engine || streaming) return;
|
|
1623
|
+
const userText = $promptIn.value.trim();
|
|
1624
|
+
if (!userText) return;
|
|
1625
|
+
|
|
1626
|
+
streaming = true;
|
|
1627
|
+
$generate.disabled = true;
|
|
1628
|
+
$stop.disabled = false;
|
|
1629
|
+
$promptIn.value = '';
|
|
1630
|
+
$tokenCounter.textContent = '';
|
|
1631
|
+
$genStats.textContent = 'Generating...';
|
|
1632
|
+
tpsWindow = [];
|
|
1633
|
+
refreshVoiceButton();
|
|
1634
|
+
await new Promise(r => setTimeout(r, 10));
|
|
1635
|
+
|
|
1636
|
+
const maxToks = parseInt($maxTokens.value, 10) || 128;
|
|
1637
|
+
const temp = parseFloat($paramTemp.value);
|
|
1638
|
+
const topP = parseFloat($paramTopp.value);
|
|
1639
|
+
const topK = parseInt($paramTopk.value, 10);
|
|
1640
|
+
const repeatPen = parseFloat($paramRepeat.value);
|
|
1641
|
+
const sysText = $systemIn.value.trim();
|
|
1642
|
+
const chatMode = $chatMode.checked;
|
|
1643
|
+
|
|
1644
|
+
appendBubble('user', userText);
|
|
1645
|
+
|
|
1646
|
+
if (chatMode) {
|
|
1647
|
+
if (history.length === 0 && sysText) {
|
|
1648
|
+
history.push({ role: 'system', content: sysText });
|
|
1649
|
+
}
|
|
1650
|
+
history.push({ role: 'user', content: userText });
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
try {
|
|
1654
|
+
let inputText;
|
|
1655
|
+
if (chatMode) {
|
|
1656
|
+
inputText = buildPrompt(history, engine.chat_template_name);
|
|
1657
|
+
} else {
|
|
1658
|
+
inputText = userText;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const promptIds = tokenizer
|
|
1662
|
+
? tokenizer.encode(inputText)
|
|
1663
|
+
: new Uint32Array([1]);
|
|
1664
|
+
|
|
1665
|
+
engine.reset();
|
|
1666
|
+
// Use begin_stream_with_params to respect sampling settings
|
|
1667
|
+
engine.begin_stream_with_params(promptIds, maxToks, temp, topP, topK, repeatPen, 0.0);
|
|
1668
|
+
const prefillMs = engine.last_prefill_ms;
|
|
1669
|
+
|
|
1670
|
+
let count = 0;
|
|
1671
|
+
let assistantText = '';
|
|
1672
|
+
const bubble = appendBubble('assistant', '', true);
|
|
1673
|
+
showPerfPanel();
|
|
1674
|
+
|
|
1675
|
+
function tick() {
|
|
1676
|
+
if (!streaming) { finishStream(); return; }
|
|
1677
|
+
const id = engine.next_token();
|
|
1678
|
+
if (id === undefined) { streaming = false; finishStream(); return; }
|
|
1679
|
+
count++;
|
|
1680
|
+
if (tokenizer) {
|
|
1681
|
+
assistantText += tokenizer.decode_one(id);
|
|
1682
|
+
} else {
|
|
1683
|
+
assistantText += `[${id}]`;
|
|
1684
|
+
}
|
|
1685
|
+
updateAssistantBubble(bubble, assistantText);
|
|
1686
|
+
$chat.scrollTop = $chat.scrollHeight;
|
|
1687
|
+
updatePerfLive(count, prefillMs);
|
|
1688
|
+
|
|
1689
|
+
// Update context counter
|
|
1690
|
+
const used = engine.tokens_used;
|
|
1691
|
+
const max = engine.max_seq_len;
|
|
1692
|
+
$tokenCounter.textContent = `${used} / ${max}`;
|
|
1693
|
+
$tokenCounter.style.color = used > max * 0.85 ? 'var(--error)' : 'var(--fg-faint)';
|
|
1694
|
+
|
|
1695
|
+
requestAnimationFrame(tick);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function finishStream() {
|
|
1699
|
+
bubble.classList.remove('streaming');
|
|
1700
|
+
finalizeAssistantBubble(bubble, assistantText);
|
|
1701
|
+
|
|
1702
|
+
const finalText = assistantText ||
|
|
1703
|
+
(!tokenizer ? '(load a tokenizer to see decoded text)' : '');
|
|
1704
|
+
if (chatMode && finalText) {
|
|
1705
|
+
history.push({ role: 'assistant', content: assistantText });
|
|
1706
|
+
}
|
|
1707
|
+
if (!tokenizer && count > 0) {
|
|
1708
|
+
bubble.textContent = `${count} token(s) generated. Load a tokenizer (step 2) to see decoded text.`;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
try {
|
|
1712
|
+
const s = JSON.parse(engine.performance_summary());
|
|
1713
|
+
finalizePerfDashboard(s);
|
|
1714
|
+
$genStats.textContent =
|
|
1715
|
+
`${s.tokens_generated} tokens | TTFT: ${s.prefill_ms.toFixed(0)}ms | ` +
|
|
1716
|
+
`${s.tokens_per_second.toFixed(1)} tok/s`;
|
|
1717
|
+
} catch (_) {
|
|
1718
|
+
$genStats.textContent = `${count} tokens generated`;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
$generate.disabled = false;
|
|
1722
|
+
$stop.disabled = true;
|
|
1723
|
+
|
|
1724
|
+
// If the user initiated this generation via the Voice button,
|
|
1725
|
+
// speak the assistant reply back through Web Speech synthesis.
|
|
1726
|
+
if (voiceAutoSpeak && finalText) {
|
|
1727
|
+
voiceMode.speak(finalText);
|
|
1728
|
+
}
|
|
1729
|
+
voiceAutoSpeak = false;
|
|
1730
|
+
refreshVoiceButton();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
requestAnimationFrame(tick);
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
appendBubble('assistant', 'Error: ' + err).classList.add('error');
|
|
1736
|
+
streaming = false;
|
|
1737
|
+
$generate.disabled = false;
|
|
1738
|
+
$stop.disabled = true;
|
|
1739
|
+
voiceAutoSpeak = false;
|
|
1740
|
+
refreshVoiceButton();
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
// Live token counter
|
|
1745
|
+
$promptIn.addEventListener('input', () => {
|
|
1746
|
+
if (!engine || !$promptIn.value) {
|
|
1747
|
+
$tokenCounter.textContent = '';
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const count = engine.count_tokens($promptIn.value);
|
|
1751
|
+
const max = engine.max_seq_len;
|
|
1752
|
+
$tokenCounter.textContent = `${count} / ${max}`;
|
|
1753
|
+
$tokenCounter.style.color = count > max * 0.85 ? 'var(--error)' : 'var(--fg-faint)';
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
// Ctrl+Enter / Cmd+Enter to send
|
|
1757
|
+
$promptIn.addEventListener('keydown', e => {
|
|
1758
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
1759
|
+
e.preventDefault();
|
|
1760
|
+
if (!$generate.disabled) $generate.click();
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
startWasm();
|
|
1765
|
+
</script>
|
|
1766
|
+
</body>
|
|
1767
|
+
</html>
|