Xenova HF Staff commited on
Commit
4d470ff
·
verified ·
1 Parent(s): a0ee54a

Upload 3 files

Browse files
Files changed (3) hide show
  1. index.css +737 -0
  2. index.html +180 -17
  3. index.js +459 -0
index.css ADDED
@@ -0,0 +1,737 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ :root {
10
+ --bg: #0b0c0f;
11
+ --surface: #131518;
12
+ --surface-2: #1a1d22;
13
+ --surface-3: #22262c;
14
+ --border: #2a2e36;
15
+ --text: #e8e4de;
16
+ --text-dim: #8a8680;
17
+ --text-muted: #5c5955;
18
+ --accent: #e8a84c;
19
+ --accent-dim: #c4862e;
20
+ --accent-glow: rgba(232, 168, 76, 0.12);
21
+ --accent-glow-strong: rgba(232, 168, 76, 0.25);
22
+ --red: #d45a5a;
23
+ --green: #6abf7b;
24
+ --radius: 12px;
25
+ --radius-sm: 8px;
26
+ --font-body: "Manrope", sans-serif;
27
+ --font-display: "Instrument Serif", serif;
28
+ --font-mono: "DM Mono", monospace;
29
+ }
30
+
31
+ html {
32
+ font-size: 16px;
33
+ }
34
+ body {
35
+ font-family: var(--font-body);
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ min-height: 100dvh;
39
+ overflow: hidden;
40
+ -webkit-font-smoothing: antialiased;
41
+ opacity: 0;
42
+ }
43
+ body.ready {
44
+ opacity: 1;
45
+ }
46
+
47
+ /* ─── Noise overlay ─── */
48
+ body::before {
49
+ content: "";
50
+ position: fixed;
51
+ inset: 0;
52
+ background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
53
+ pointer-events: none;
54
+ z-index: 9999;
55
+ }
56
+
57
+ /* ─── Screens ─── */
58
+ .screen {
59
+ display: none;
60
+ width: 100%;
61
+ height: 100dvh;
62
+ }
63
+ .screen.active {
64
+ display: flex;
65
+ }
66
+
67
+ #landing {
68
+ flex-direction: column;
69
+ align-items: center;
70
+ justify-content: center;
71
+ text-align: center;
72
+ position: relative;
73
+ overflow: hidden;
74
+ }
75
+
76
+ .landing-glow {
77
+ position: absolute;
78
+ width: 600px;
79
+ height: 600px;
80
+ border-radius: 50%;
81
+ background: radial-gradient(
82
+ circle,
83
+ var(--accent-glow-strong) 0%,
84
+ transparent 70%
85
+ );
86
+ top: 50%;
87
+ left: 50%;
88
+ transform: translate(-50%, -55%);
89
+ animation: breathe 6s ease-in-out infinite;
90
+ pointer-events: none;
91
+ }
92
+ @keyframes breathe {
93
+ 0%,
94
+ 100% {
95
+ opacity: 0.5;
96
+ transform: translate(-50%, -55%) scale(1);
97
+ }
98
+ 50% {
99
+ opacity: 0.8;
100
+ transform: translate(-50%, -55%) scale(1.12);
101
+ }
102
+ }
103
+
104
+ .landing-tag {
105
+ font-family: var(--font-mono);
106
+ font-size: 0.72rem;
107
+ letter-spacing: 0.15em;
108
+ text-transform: uppercase;
109
+ color: var(--accent);
110
+ background: var(--accent-glow);
111
+ border: 1px solid rgba(232, 168, 76, 0.2);
112
+ padding: 6px 16px;
113
+ border-radius: 100px;
114
+ margin-bottom: 28px;
115
+ position: relative;
116
+ }
117
+
118
+ .landing-title {
119
+ font-family: var(--font-display);
120
+ font-size: clamp(3rem, 8vw, 5.5rem);
121
+ font-weight: 400;
122
+ line-height: 1.05;
123
+ letter-spacing: -0.02em;
124
+ color: var(--text);
125
+ position: relative;
126
+ margin-bottom: 16px;
127
+ }
128
+ .landing-title em {
129
+ font-style: italic;
130
+ color: var(--accent);
131
+ }
132
+
133
+ .landing-sub {
134
+ font-size: 1.05rem;
135
+ color: var(--text-dim);
136
+ max-width: 520px;
137
+ line-height: 1.65;
138
+ margin-bottom: 44px;
139
+ position: relative;
140
+ font-weight: 300;
141
+ }
142
+
143
+ .landing-specs {
144
+ display: flex;
145
+ gap: 32px;
146
+ margin-bottom: 48px;
147
+ position: relative;
148
+ }
149
+ .spec {
150
+ text-align: center;
151
+ }
152
+ .spec-value {
153
+ font-family: var(--font-mono);
154
+ font-size: 1rem;
155
+ font-weight: 500;
156
+ color: var(--text);
157
+ }
158
+ .spec-label {
159
+ font-size: 0.7rem;
160
+ letter-spacing: 0.1em;
161
+ text-transform: uppercase;
162
+ color: var(--text-muted);
163
+ margin-top: 4px;
164
+ }
165
+
166
+ .btn-load-group {
167
+ display: inline-flex;
168
+ align-items: stretch;
169
+ border-radius: 100px;
170
+ position: relative;
171
+ box-shadow: 0 0 40px var(--accent-glow-strong);
172
+ transition: all 0.25s;
173
+ }
174
+ .btn-load-group:hover {
175
+ transform: translateY(-2px);
176
+ box-shadow:
177
+ 0 0 60px var(--accent-glow-strong),
178
+ 0 8px 30px rgba(0, 0, 0, 0.4);
179
+ }
180
+ .btn-load-group:active {
181
+ transform: translateY(0);
182
+ }
183
+ .btn-load {
184
+ font-family: var(--font-body);
185
+ font-size: 0.92rem;
186
+ font-weight: 600;
187
+ letter-spacing: 0.03em;
188
+ color: var(--bg);
189
+ background: var(--accent);
190
+ border: none;
191
+ padding: 16px 12px 16px 36px;
192
+ border-radius: 100px 0 0 100px;
193
+ cursor: pointer;
194
+ }
195
+ .btn-load-arrow {
196
+ color: var(--bg);
197
+ background: var(--accent);
198
+ border: none;
199
+ border-left: 1px solid rgba(0, 0, 0, 0.15);
200
+ padding: 16px 20px 16px 12px;
201
+ border-radius: 0 100px 100px 0;
202
+ cursor: pointer;
203
+ font-size: 1rem;
204
+ line-height: 1;
205
+ }
206
+ .model-select {
207
+ position: absolute;
208
+ inset: 0;
209
+ opacity: 0;
210
+ cursor: pointer;
211
+ pointer-events: none;
212
+ }
213
+ .model-select option {
214
+ background: var(--surface-2);
215
+ color: var(--text);
216
+ }
217
+
218
+ .landing-footer {
219
+ position: absolute;
220
+ bottom: 28px;
221
+ font-family: var(--font-mono);
222
+ font-size: 0.68rem;
223
+ color: var(--text-muted);
224
+ letter-spacing: 0.06em;
225
+ }
226
+ .landing-footer a {
227
+ color: var(--text-dim);
228
+ text-decoration: none;
229
+ }
230
+ .landing-footer a:hover {
231
+ color: var(--accent);
232
+ }
233
+
234
+ #loading {
235
+ flex-direction: column;
236
+ align-items: center;
237
+ justify-content: center;
238
+ gap: 36px;
239
+ }
240
+
241
+ .loader-ring {
242
+ width: 72px;
243
+ height: 72px;
244
+ border: 2px solid var(--border);
245
+ border-top-color: var(--accent);
246
+ border-radius: 50%;
247
+ animation: spin 1s linear infinite;
248
+ }
249
+ @keyframes spin {
250
+ to {
251
+ transform: rotate(360deg);
252
+ }
253
+ }
254
+
255
+ .loader-text {
256
+ font-family: var(--font-mono);
257
+ font-size: 0.82rem;
258
+ color: var(--text-dim);
259
+ letter-spacing: 0.05em;
260
+ text-align: center;
261
+ }
262
+ .loader-sub {
263
+ font-size: 0.72rem;
264
+ color: var(--text-muted);
265
+ margin-top: 8px;
266
+ text-align: center;
267
+ line-height: 1.5;
268
+ }
269
+
270
+ #chat {
271
+ flex-direction: column;
272
+ height: 100dvh;
273
+ }
274
+
275
+ /* Header */
276
+ .chat-header {
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: space-between;
280
+ padding: 16px 24px;
281
+ border-bottom: 1px solid var(--border);
282
+ background: var(--surface);
283
+ flex-shrink: 0;
284
+ }
285
+ .chat-header-left {
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 12px;
289
+ }
290
+ .chat-avatar {
291
+ width: 34px;
292
+ height: 34px;
293
+ border-radius: 10px;
294
+ background: linear-gradient(135deg, var(--accent), var(--accent-dim));
295
+ display: flex;
296
+ align-items: center;
297
+ justify-content: center;
298
+ font-family: var(--font-display);
299
+ font-size: 1rem;
300
+ color: var(--bg);
301
+ font-weight: 600;
302
+ }
303
+ .chat-header-title {
304
+ font-family: var(--font-display);
305
+ font-size: 1.15rem;
306
+ }
307
+ .chat-header-status {
308
+ font-family: var(--font-mono);
309
+ font-size: 0.65rem;
310
+ color: var(--green);
311
+ letter-spacing: 0.06em;
312
+ display: flex;
313
+ align-items: center;
314
+ gap: 5px;
315
+ }
316
+ .chat-header-status::before {
317
+ content: "";
318
+ width: 6px;
319
+ height: 6px;
320
+ background: var(--green);
321
+ border-radius: 50%;
322
+ }
323
+ .chat-header-controls {
324
+ display: flex;
325
+ align-items: center;
326
+ gap: 12px;
327
+ }
328
+
329
+ /* Reasoning toggle */
330
+ .toggle-reasoning {
331
+ display: flex;
332
+ align-items: center;
333
+ gap: 8px;
334
+ cursor: pointer;
335
+ user-select: none;
336
+ }
337
+ .toggle-reasoning input {
338
+ display: none;
339
+ }
340
+ .toggle-slider {
341
+ width: 32px;
342
+ height: 18px;
343
+ background: var(--border);
344
+ border-radius: 100px;
345
+ position: relative;
346
+ transition: background 0.2s;
347
+ }
348
+ .toggle-slider::after {
349
+ content: "";
350
+ position: absolute;
351
+ width: 14px;
352
+ height: 14px;
353
+ border-radius: 50%;
354
+ background: var(--text-muted);
355
+ top: 2px;
356
+ left: 2px;
357
+ transition: all 0.2s;
358
+ }
359
+ .toggle-reasoning input:checked + .toggle-slider {
360
+ background: var(--accent-glow);
361
+ }
362
+ .toggle-reasoning input:checked + .toggle-slider::after {
363
+ background: var(--accent);
364
+ left: 16px;
365
+ }
366
+ .toggle-label {
367
+ font-family: var(--font-mono);
368
+ font-size: 0.65rem;
369
+ letter-spacing: 0.06em;
370
+ text-transform: uppercase;
371
+ color: var(--text-muted);
372
+ transition: color 0.2s;
373
+ }
374
+ .toggle-reasoning input:checked ~ .toggle-label {
375
+ color: var(--accent);
376
+ }
377
+
378
+ .btn-reset {
379
+ font-family: var(--font-mono);
380
+ font-size: 0.7rem;
381
+ letter-spacing: 0.06em;
382
+ text-transform: uppercase;
383
+ color: var(--text-muted);
384
+ background: transparent;
385
+ border: 1px solid var(--border);
386
+ padding: 7px 14px;
387
+ border-radius: 100px;
388
+ cursor: pointer;
389
+ transition: all 0.2s;
390
+ }
391
+ .btn-reset:hover {
392
+ color: var(--red);
393
+ border-color: var(--red);
394
+ }
395
+
396
+ /* Messages */
397
+ .chat-messages {
398
+ flex: 1;
399
+ overflow-y: auto;
400
+ padding: 24px;
401
+ display: flex;
402
+ flex-direction: column;
403
+ gap: 8px;
404
+ scroll-behavior: smooth;
405
+ }
406
+ .chat-messages::-webkit-scrollbar {
407
+ width: 5px;
408
+ }
409
+ .chat-messages::-webkit-scrollbar-track {
410
+ background: transparent;
411
+ }
412
+ .chat-messages::-webkit-scrollbar-thumb {
413
+ background: var(--border);
414
+ border-radius: 10px;
415
+ }
416
+
417
+ .msg {
418
+ max-width: 72%;
419
+ padding: 14px 18px;
420
+ border-radius: var(--radius);
421
+ border: 1px solid var(--border);
422
+ color: var(--text);
423
+ font-size: 0.9rem;
424
+ line-height: 1.65;
425
+ animation: msgIn 0.3s ease;
426
+ word-wrap: break-word;
427
+ white-space: pre-wrap;
428
+ }
429
+ @keyframes msgIn {
430
+ from {
431
+ opacity: 0;
432
+ transform: translateY(8px);
433
+ }
434
+ to {
435
+ opacity: 1;
436
+ transform: translateY(0);
437
+ }
438
+ }
439
+ .msg.user {
440
+ align-self: flex-end;
441
+ background: var(--surface-3);
442
+ border-bottom-right-radius: 4px;
443
+ }
444
+ .msg.assistant {
445
+ align-self: flex-start;
446
+ background: var(--surface);
447
+ border-bottom-left-radius: 4px;
448
+ }
449
+ .msg.assistant.generating {
450
+ border-color: var(--accent);
451
+ box-shadow: 0 0 20px var(--accent-glow);
452
+ }
453
+
454
+ .msg-image {
455
+ max-width: 220px;
456
+ max-height: 180px;
457
+ border-radius: var(--radius-sm);
458
+ margin-bottom: 8px;
459
+ display: block;
460
+ object-fit: cover;
461
+ border: 1px solid var(--border);
462
+ }
463
+
464
+ .msg-role {
465
+ font-family: var(--font-mono);
466
+ font-size: 0.62rem;
467
+ letter-spacing: 0.1em;
468
+ text-transform: uppercase;
469
+ color: var(--text-muted);
470
+ margin-bottom: 6px;
471
+ }
472
+ .msg.assistant .msg-role {
473
+ color: var(--accent-dim);
474
+ }
475
+
476
+ .thinking-dots span {
477
+ display: inline-block;
478
+ width: 6px;
479
+ height: 6px;
480
+ border-radius: 50%;
481
+ background: var(--accent);
482
+ margin-right: 4px;
483
+ animation: dot 1.2s ease-in-out infinite;
484
+ }
485
+ .thinking-dots span:nth-child(2) {
486
+ animation-delay: 0.2s;
487
+ }
488
+ .thinking-dots span:nth-child(3) {
489
+ animation-delay: 0.4s;
490
+ }
491
+ @keyframes dot {
492
+ 0%,
493
+ 80%,
494
+ 100% {
495
+ opacity: 0.25;
496
+ transform: scale(0.8);
497
+ }
498
+ 40% {
499
+ opacity: 1;
500
+ transform: scale(1);
501
+ }
502
+ }
503
+
504
+ /* Generation stats */
505
+ .msg-stats {
506
+ font-family: var(--font-mono);
507
+ font-size: 0.6rem;
508
+ letter-spacing: 0.04em;
509
+ color: var(--text-muted);
510
+ margin-top: 8px;
511
+ padding-top: 6px;
512
+ border-top: 1px solid var(--border);
513
+ }
514
+
515
+ /* Thinking block (collapsible) */
516
+ .msg-thinking-label {
517
+ font-family: var(--font-mono);
518
+ font-size: 0.6rem;
519
+ letter-spacing: 0.08em;
520
+ text-transform: uppercase;
521
+ color: var(--accent-dim);
522
+ margin-bottom: 4px;
523
+ cursor: pointer;
524
+ user-select: none;
525
+ display: flex;
526
+ align-items: center;
527
+ gap: 4px;
528
+ }
529
+ .msg-thinking-label:hover {
530
+ color: var(--accent);
531
+ }
532
+ .msg-thinking-chevron {
533
+ font-size: 0.55rem;
534
+ transition: transform 0.2s;
535
+ }
536
+ .msg-thinking {
537
+ font-size: 0.8rem;
538
+ color: var(--text-muted);
539
+ border-left: 2px solid var(--border);
540
+ padding-left: 10px;
541
+ margin-bottom: 8px;
542
+ max-height: none;
543
+ overflow-y: auto;
544
+ }
545
+ .msg-thinking.collapsing {
546
+ overflow: hidden;
547
+ transition:
548
+ max-height 0.3s ease,
549
+ opacity 0.2s ease,
550
+ margin-bottom 0.2s ease;
551
+ }
552
+ .msg-thinking.collapsed {
553
+ max-height: 0 !important;
554
+ opacity: 0;
555
+ margin-bottom: 0;
556
+ overflow: hidden;
557
+ }
558
+
559
+ /* Input area */
560
+ .chat-input-area {
561
+ padding: 16px 24px 20px;
562
+ border-top: 1px solid var(--border);
563
+ background: var(--surface);
564
+ flex-shrink: 0;
565
+ }
566
+
567
+ .image-preview-bar {
568
+ display: none;
569
+ align-items: center;
570
+ gap: 10px;
571
+ margin-bottom: 12px;
572
+ padding: 8px 12px;
573
+ background: var(--surface-2);
574
+ border: 1px solid var(--border);
575
+ border-radius: var(--radius-sm);
576
+ }
577
+ .image-preview-bar.visible {
578
+ display: flex;
579
+ }
580
+ .image-preview-thumb {
581
+ width: 44px;
582
+ height: 44px;
583
+ border-radius: 6px;
584
+ object-fit: cover;
585
+ border: 1px solid var(--border);
586
+ }
587
+ .image-preview-name {
588
+ font-family: var(--font-mono);
589
+ font-size: 0.75rem;
590
+ color: var(--text-dim);
591
+ flex: 1;
592
+ overflow: hidden;
593
+ text-overflow: ellipsis;
594
+ white-space: nowrap;
595
+ }
596
+ .btn-remove-image {
597
+ background: none;
598
+ border: none;
599
+ color: var(--text-muted);
600
+ font-size: 1.1rem;
601
+ cursor: pointer;
602
+ padding: 4px;
603
+ transition: color 0.2s;
604
+ }
605
+ .btn-remove-image:hover {
606
+ color: var(--red);
607
+ }
608
+
609
+ .chat-input-row {
610
+ display: flex;
611
+ align-items: flex-end;
612
+ gap: 10px;
613
+ }
614
+
615
+ .btn-attach,
616
+ .btn-send {
617
+ width: 42px;
618
+ height: 42px;
619
+ border-radius: 10px;
620
+ display: flex;
621
+ align-items: center;
622
+ justify-content: center;
623
+ flex-shrink: 0;
624
+ cursor: pointer;
625
+ transition: all 0.2s;
626
+ }
627
+ .btn-attach {
628
+ background: var(--surface-2);
629
+ border: 1px solid var(--border);
630
+ color: var(--text-dim);
631
+ font-size: 1.2rem;
632
+ }
633
+ .btn-attach:hover:not(:disabled) {
634
+ border-color: var(--accent);
635
+ color: var(--accent);
636
+ background: var(--accent-glow);
637
+ }
638
+ .btn-attach:disabled,
639
+ .btn-send:disabled {
640
+ opacity: 0.35;
641
+ cursor: not-allowed;
642
+ }
643
+
644
+ .input-wrap {
645
+ flex: 1;
646
+ position: relative;
647
+ }
648
+ .input-wrap textarea {
649
+ width: 100%;
650
+ min-height: 42px;
651
+ max-height: 140px;
652
+ padding: 10px 16px;
653
+ background: var(--surface-2);
654
+ border: 1px solid var(--border);
655
+ border-radius: 10px;
656
+ color: var(--text);
657
+ font-family: var(--font-body);
658
+ font-size: 0.88rem;
659
+ line-height: 1.5;
660
+ resize: none;
661
+ outline: none;
662
+ transition: border-color 0.2s;
663
+ }
664
+ .input-wrap textarea::placeholder {
665
+ color: var(--text-muted);
666
+ }
667
+ .input-wrap textarea:focus {
668
+ border-color: var(--accent);
669
+ }
670
+
671
+ .btn-send {
672
+ background: var(--accent);
673
+ border: none;
674
+ color: var(--bg);
675
+ font-size: 1.1rem;
676
+ }
677
+ .btn-send .icon-stop {
678
+ display: none;
679
+ }
680
+ .btn-send.stopping {
681
+ background: var(--red);
682
+ }
683
+ .btn-send.stopping .icon-send {
684
+ display: none;
685
+ }
686
+ .btn-send.stopping .icon-stop {
687
+ display: block;
688
+ }
689
+ .btn-send:not(:disabled):hover {
690
+ transform: translateY(-1px);
691
+ box-shadow: 0 4px 20px var(--accent-glow-strong);
692
+ }
693
+ .btn-send.stopping:not(:disabled):hover {
694
+ box-shadow: 0 4px 20px rgba(212, 90, 90, 0.3);
695
+ }
696
+
697
+ .chat-footer-note {
698
+ font-family: var(--font-mono);
699
+ font-size: 0.62rem;
700
+ color: var(--text-muted);
701
+ text-align: center;
702
+ margin-top: 10px;
703
+ letter-spacing: 0.04em;
704
+ }
705
+
706
+ /* ─── Error banner ─── */
707
+ .error-banner {
708
+ display: none;
709
+ padding: 12px 18px;
710
+ background: rgba(212, 90, 90, 0.1);
711
+ border: 1px solid rgba(212, 90, 90, 0.3);
712
+ border-radius: var(--radius-sm);
713
+ color: var(--red);
714
+ font-size: 0.82rem;
715
+ margin: 12px 24px 0;
716
+ }
717
+ .error-banner.visible {
718
+ display: block;
719
+ }
720
+
721
+ /* ─── Welcome msg ─── */
722
+ .welcome-msg {
723
+ text-align: center;
724
+ padding: 48px 24px;
725
+ color: var(--text-muted);
726
+ }
727
+ .welcome-msg h3 {
728
+ font-family: var(--font-display);
729
+ font-size: 1.4rem;
730
+ color: var(--text-dim);
731
+ margin-bottom: 8px;
732
+ font-weight: 400;
733
+ }
734
+ .welcome-msg p {
735
+ font-size: 0.85rem;
736
+ line-height: 1.6;
737
+ }
index.html CHANGED
@@ -1,19 +1,182 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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
+ <title>Qwen 3.5 — In-Browser Vision Chat</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Instrument+Serif:ital@0;1&family=Manrope:wght@300;400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link
14
+ rel="icon"
15
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚡️</text></svg>"
16
+ />
17
+ <link rel="stylesheet" href="index.css" />
18
+ </head>
19
+ <body>
20
+ <div id="landing" class="screen active">
21
+ <div class="landing-glow"></div>
22
+ <div class="landing-tag">Multimodal AI · 100% Local</div>
23
+ <h1 class="landing-title">Qwen 3.5 <em>Vision</em></h1>
24
+ <p class="landing-sub">
25
+ Run a multimodal vision-language model entirely in your browser. No
26
+ server, no API keys — powered by Transformers.js and WebGPU.
27
+ </p>
28
+ <div class="landing-specs">
29
+ <div class="spec">
30
+ <div class="spec-value">Vision + Language</div>
31
+ <div class="spec-label">Unified Multimodal</div>
32
+ </div>
33
+ <div class="spec">
34
+ <div class="spec-value">201 Languages</div>
35
+ <div class="spec-label">Global Coverage</div>
36
+ </div>
37
+ <div class="spec">
38
+ <div class="spec-value">Reasoning</div>
39
+ <div class="spec-label">Code · Agents · Visual</div>
40
+ </div>
41
+ </div>
42
+ <div class="btn-load-group">
43
+ <button class="btn-load" id="btnLoad">
44
+ Load Model (<span id="modelSizeLabel">0.8B</span>)
45
+ </button>
46
+ <button
47
+ class="btn-load-arrow"
48
+ id="btnModelArrow"
49
+ title="Choose model size"
50
+ >
51
+
52
+ </button>
53
+ <select class="model-select" id="modelSelect">
54
+ <option value="onnx-community/Qwen3.5-0.8B-ONNX">0.8B</option>
55
+ <option value="onnx-community/Qwen3.5-2B-ONNX">2B</option>
56
+ <option value="onnx-community/Qwen3.5-4B-ONNX">4B</option>
57
+ </select>
58
+ </div>
59
+ <div class="landing-footer">
60
+ Built with
61
+ <a href="https://huggingface.co/docs/transformers.js" target="_blank"
62
+ >Transformers.js</a
63
+ >
64
+ </div>
65
+ </div>
66
+
67
+ <div id="loading" class="screen">
68
+ <div class="loader-ring"></div>
69
+ <div>
70
+ <div class="loader-text" id="loaderText">Initializing model…</div>
71
+ <div class="loader-sub">
72
+ Model weights are cached for future visits.
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div id="chat" class="screen">
78
+ <div class="chat-header">
79
+ <div class="chat-header-left">
80
+ <div class="chat-avatar">Q</div>
81
+ <div>
82
+ <div class="chat-header-title">Qwen 3.5 Vision</div>
83
+ <div class="chat-header-status">Ready on WebGPU</div>
84
+ </div>
85
+ </div>
86
+ <div class="chat-header-controls">
87
+ <label
88
+ class="toggle-reasoning"
89
+ title="Let the model think step-by-step before answering"
90
+ >
91
+ <input type="checkbox" id="reasoningToggle" />
92
+ <span class="toggle-slider"></span>
93
+ <span class="toggle-label">Reasoning</span>
94
+ </label>
95
+ <button class="btn-reset" id="btnReset">Reset Chat</button>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="error-banner" id="errorBanner"></div>
100
+
101
+ <div class="chat-messages" id="chatMessages">
102
+ <div class="welcome-msg">
103
+ <h3>Start a conversation</h3>
104
+ <p>
105
+ Optionally attach an image, then type your message.<br />The model
106
+ runs entirely in your browser.
107
+ </p>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="chat-input-area">
112
+ <div class="image-preview-bar" id="imagePreview">
113
+ <img class="image-preview-thumb" id="imageThumb" src="" alt="" />
114
+ <span class="image-preview-name" id="imageName"></span>
115
+ <button class="btn-remove-image" id="btnRemoveImage">&times;</button>
116
+ </div>
117
+ <div class="chat-input-row">
118
+ <button class="btn-attach" id="btnAttach" title="Attach image">
119
+ <svg
120
+ width="18"
121
+ height="18"
122
+ viewBox="0 0 24 24"
123
+ fill="none"
124
+ stroke="currentColor"
125
+ stroke-width="2"
126
+ stroke-linecap="round"
127
+ stroke-linejoin="round"
128
+ >
129
+ <rect x="3" y="3" width="18" height="18" rx="2" />
130
+ <circle cx="8.5" cy="8.5" r="1.5" />
131
+ <polyline points="21 15 16 10 5 21" />
132
+ </svg>
133
+ </button>
134
+ <input
135
+ type="file"
136
+ id="fileInput"
137
+ accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
138
+ hidden
139
+ />
140
+ <div class="input-wrap">
141
+ <textarea
142
+ id="msgInput"
143
+ rows="1"
144
+ placeholder="Describe this image…"
145
+ ></textarea>
146
+ </div>
147
+ <button class="btn-send" id="btnSend" disabled title="Send">
148
+ <svg
149
+ class="icon-send"
150
+ width="18"
151
+ height="18"
152
+ viewBox="0 0 24 24"
153
+ fill="none"
154
+ stroke="currentColor"
155
+ stroke-width="2.5"
156
+ stroke-linecap="round"
157
+ stroke-linejoin="round"
158
+ >
159
+ <line x1="22" y1="2" x2="11" y2="13" />
160
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
161
+ </svg>
162
+ <svg
163
+ class="icon-stop"
164
+ width="16"
165
+ height="16"
166
+ viewBox="0 0 24 24"
167
+ fill="currentColor"
168
+ >
169
+ <rect x="4" y="4" width="16" height="16" rx="2" />
170
+ </svg>
171
+ </button>
172
+ </div>
173
+ <div class="chat-footer-note">
174
+ No chats are sent to a server. Everything runs locally in your
175
+ browser. AI can make mistakes. Check important info.
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <script src="index.js" type="module"></script>
181
+ </body>
182
  </html>
index.js ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoProcessor,
3
+ Qwen3_5ForConditionalGeneration,
4
+ RawImage,
5
+ TextStreamer,
6
+ InterruptableStoppingCriteria,
7
+ } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.6";
8
+
9
+ /* ─── State ─── */
10
+ let processor = null;
11
+ let model = null;
12
+ let conversationImage = null; // single RawImage used across the conversation
13
+ let attachedImage = null; // { raw: RawImage, dataURL: string, name: string } | null
14
+ let isGenerating = false;
15
+ let pastKeyValues = null; // cached KV from previous generation
16
+ let imageGridThw = null; // cached image_grid_thw from initial image inputs
17
+ let promptHistory = ""; // raw prompt text built up across turns
18
+ const stoppingCriteria = new InterruptableStoppingCriteria();
19
+
20
+ /* ─── Wait for fonts, then reveal ─── */
21
+ document.fonts.ready.then(() => document.body.classList.add("ready"));
22
+
23
+ /* ─── DOM refs ─── */
24
+ const $ = (id) => document.getElementById(id);
25
+ const $loaderTx = $("loaderText");
26
+ const $messages = $("chatMessages");
27
+ const $input = $("msgInput");
28
+ const $btnSend = $("btnSend");
29
+ const $btnLoad = $("btnLoad");
30
+ const $btnReset = $("btnReset");
31
+ const $btnAttach = $("btnAttach");
32
+ const $fileInput = $("fileInput");
33
+ const $imgPrev = $("imagePreview");
34
+ const $imgThumb = $("imageThumb");
35
+ const $imgName = $("imageName");
36
+ const $btnRemImg = $("btnRemoveImage");
37
+ const $errBanner = $("errorBanner");
38
+ const $reasoning = $("reasoningToggle");
39
+ const $modelSelect = $("modelSelect");
40
+ const $modelSizeLabel = $("modelSizeLabel");
41
+ const $btnModelArrow = $("btnModelArrow");
42
+
43
+ /* ─── Model selector ─── */
44
+ $btnModelArrow.addEventListener("click", () => {
45
+ $modelSelect.style.pointerEvents = "auto";
46
+ $modelSelect.focus();
47
+ $modelSelect.showPicker?.();
48
+ });
49
+ $modelSelect.addEventListener("change", () => {
50
+ $modelSizeLabel.textContent = $modelSelect.selectedOptions[0].textContent;
51
+ $modelSelect.style.pointerEvents = "none";
52
+ });
53
+ $modelSelect.addEventListener("blur", () => {
54
+ $modelSelect.style.pointerEvents = "none";
55
+ });
56
+
57
+ /* ─── Screen switching ─── */
58
+ function showScreen(id) {
59
+ document
60
+ .querySelectorAll(".screen")
61
+ .forEach((s) => s.classList.toggle("active", s.id === id));
62
+ }
63
+
64
+ /* ─── Model loading ─── */
65
+ $btnLoad.addEventListener("click", async () => {
66
+ showScreen("loading");
67
+ try {
68
+ const model_id = $modelSelect.value;
69
+ const sizeLabel = $modelSizeLabel.textContent;
70
+
71
+ $loaderTx.textContent = "Loading processor…";
72
+ processor = await AutoProcessor.from_pretrained(model_id);
73
+
74
+ $loaderTx.textContent = "Loading model weights…";
75
+ model = await Qwen3_5ForConditionalGeneration.from_pretrained(model_id, {
76
+ dtype: {
77
+ embed_tokens: "q4",
78
+ vision_encoder: "fp16",
79
+ decoder_model_merged: "q4",
80
+ },
81
+ device: "webgpu",
82
+ });
83
+
84
+ $loaderTx.textContent = "Ready!";
85
+ document.querySelector(".chat-header-title").textContent =
86
+ `Qwen 3.5 Vision · ${sizeLabel}`;
87
+ setTimeout(() => showScreen("chat"), 400);
88
+ } catch (err) {
89
+ console.error(err);
90
+ $loaderTx.textContent = "Failed to load model";
91
+ document.querySelector(".loader-sub").textContent = err.message;
92
+ document.querySelector(".loader-ring").style.borderTopColor = "var(--red)";
93
+ }
94
+ });
95
+
96
+ /* ─── Image attachment ─── */
97
+ $btnAttach.addEventListener("click", () => {
98
+ if ($btnAttach.disabled) return;
99
+ $fileInput.click();
100
+ });
101
+
102
+ $fileInput.addEventListener("change", async (e) => {
103
+ const file = e.target.files?.[0];
104
+ if (!file) return;
105
+
106
+ const dataURL = URL.createObjectURL(file);
107
+ const raw = await RawImage.read(dataURL);
108
+ const resized = await raw.resize(448, 448);
109
+
110
+ attachedImage = { raw: resized, dataURL, name: file.name };
111
+ $imgThumb.src = dataURL;
112
+ $imgName.textContent = file.name;
113
+ $imgPrev.classList.add("visible");
114
+ updateSendBtn();
115
+ $fileInput.value = "";
116
+ });
117
+
118
+ $btnRemImg.addEventListener("click", clearAttachment);
119
+
120
+ function clearAttachment() {
121
+ attachedImage = null;
122
+ $imgPrev.classList.remove("visible");
123
+ $imgThumb.src = "";
124
+ $imgName.textContent = "";
125
+ updateSendBtn();
126
+ }
127
+
128
+ /* ─── Input handling ─── */
129
+ $input.addEventListener("input", () => {
130
+ $input.style.height = "auto";
131
+ $input.style.height = Math.min($input.scrollHeight, 140) + "px";
132
+ updateSendBtn();
133
+ });
134
+
135
+ $input.addEventListener("keydown", (e) => {
136
+ if (e.key === "Enter" && !e.shiftKey) {
137
+ e.preventDefault();
138
+ if (!isGenerating) sendMessage();
139
+ }
140
+ });
141
+
142
+ $btnSend.addEventListener("click", () => {
143
+ if (isGenerating) {
144
+ stoppingCriteria.interrupt();
145
+ } else {
146
+ sendMessage();
147
+ }
148
+ });
149
+
150
+ function updateSendBtn() {
151
+ if (isGenerating) {
152
+ $btnSend.disabled = false;
153
+ $btnSend.classList.add("stopping");
154
+ } else {
155
+ $btnSend.classList.remove("stopping");
156
+ $btnSend.disabled = !$input.value.trim() && !attachedImage;
157
+ }
158
+ }
159
+
160
+ function disposePastKeyValues() {
161
+ if (pastKeyValues) {
162
+ for (const tensor of Object.values(pastKeyValues)) {
163
+ tensor.dispose();
164
+ }
165
+ pastKeyValues = null;
166
+ }
167
+ }
168
+
169
+ /* ─── Reset ─── */
170
+ $btnReset.addEventListener("click", () => {
171
+ conversationImage = null;
172
+ attachedImage = null;
173
+ disposePastKeyValues();
174
+ stoppingCriteria.reset();
175
+ imageGridThw = null;
176
+ promptHistory = "";
177
+ $imgPrev.classList.remove("visible");
178
+ $btnAttach.disabled = false;
179
+ $messages.innerHTML = `
180
+ <div class="welcome-msg">
181
+ <h3>Start a conversation</h3>
182
+ <p>Optionally attach an image, then type your message.<br>The model runs entirely in your browser.</p>
183
+ </div>`;
184
+ $errBanner.classList.remove("visible");
185
+ $input.value = "";
186
+ $input.style.height = "auto";
187
+ updateSendBtn();
188
+ });
189
+
190
+ /* ─── Chat logic ─── */
191
+ async function sendMessage() {
192
+ if (isGenerating) return;
193
+ const text = $input.value.trim();
194
+ if (!text && !attachedImage) return;
195
+
196
+ $errBanner.classList.remove("visible");
197
+
198
+ // Clear welcome
199
+ const welcome = $messages.querySelector(".welcome-msg");
200
+ if (welcome) welcome.remove();
201
+
202
+ // Capture attached image before clearing
203
+ const img = attachedImage;
204
+ if (img) conversationImage = img.raw;
205
+
206
+ // Render user message in the UI
207
+ appendMessage("user", text, img?.dataURL);
208
+
209
+ // Clear input fields
210
+ $input.value = "";
211
+ $input.style.height = "auto";
212
+ clearAttachment();
213
+
214
+ // Disable image attach for the rest of this conversation if we just used one
215
+ if (conversationImage) {
216
+ $btnAttach.disabled = true;
217
+ }
218
+
219
+ // Start generating
220
+ isGenerating = true;
221
+ updateSendBtn();
222
+
223
+ const assistantEl = appendMessage("assistant", "", null, true);
224
+ const contentEl = assistantEl.querySelector(".msg-content");
225
+
226
+ try {
227
+ // Build prompt manually (can't use apply_chat_template with PKV approach)
228
+ const isFirstTurn = promptHistory === "";
229
+
230
+ // Build the user turn
231
+ const enableThinking = $reasoning.checked;
232
+ let userPrompt = "<|im_start|>user\n";
233
+ if (img?.raw) {
234
+ userPrompt += "<|vision_start|><|image_pad|><|vision_end|>";
235
+ }
236
+ userPrompt += (text || "") + "<|im_end|>\n";
237
+ userPrompt += enableThinking
238
+ ? "<|im_start|>assistant\n<think>\n"
239
+ : "<|im_start|>assistant\n<think>\n\n</think>\n\n";
240
+
241
+ let inputs, generateArgs;
242
+
243
+ if (img?.raw) {
244
+ // Image attached: must do a full encode (no PKV reuse possible)
245
+ // Rebuild the full prompt including any prior conversation
246
+ const fullPrompt = (isFirstTurn ? "" : promptHistory + "\n") + userPrompt;
247
+ inputs = await processor(fullPrompt, img.raw);
248
+
249
+ // Cache image_grid_thw for future PKV continuation turns
250
+ if (inputs.image_grid_thw) {
251
+ imageGridThw = inputs.image_grid_thw;
252
+ }
253
+
254
+ // Discard past key values — image changes the encoded sequence
255
+ disposePastKeyValues();
256
+ generateArgs = { ...inputs };
257
+ } else if (isFirstTurn) {
258
+ // First turn, text only: full encode, no image
259
+ inputs = await processor(userPrompt);
260
+ generateArgs = { ...inputs };
261
+ } else {
262
+ // Continuation: use past_key_values, no image re-encoding
263
+ const continuationPrompt = promptHistory + "\n" + userPrompt;
264
+ inputs = await processor(continuationPrompt);
265
+
266
+ generateArgs = {
267
+ ...inputs,
268
+ past_key_values: pastKeyValues,
269
+ };
270
+
271
+ // Pass image_grid_thw if we had an image earlier
272
+ if (imageGridThw) {
273
+ generateArgs.image_grid_thw = imageGridThw;
274
+ }
275
+ }
276
+
277
+ let fullText = "";
278
+ let thinkingDone = !enableThinking;
279
+ let thinkingEl = null;
280
+ let thinkingContentEl = null;
281
+
282
+ let thinkingLabel = null;
283
+ let chevron = null;
284
+
285
+ let tokenCount = 0;
286
+ let startTime = null;
287
+
288
+ if (enableThinking) {
289
+ // Add collapsible thinking block before the content area
290
+ thinkingLabel = document.createElement("div");
291
+ thinkingLabel.className = "msg-thinking-label";
292
+ chevron = document.createElement("span");
293
+ chevron.className = "msg-thinking-chevron";
294
+ chevron.textContent = "▼";
295
+ thinkingLabel.append(chevron, " Thinking");
296
+
297
+ thinkingEl = document.createElement("div");
298
+ thinkingEl.className = "msg-thinking";
299
+ contentEl.before(thinkingLabel, thinkingEl);
300
+ thinkingContentEl = thinkingEl;
301
+
302
+ thinkingLabel.addEventListener("click", () => {
303
+ if (thinkingEl.classList.contains("collapsed")) {
304
+ thinkingEl.classList.add("collapsing");
305
+ thinkingEl.classList.remove("collapsed");
306
+ thinkingEl.style.maxHeight = thinkingEl.scrollHeight + "px";
307
+ thinkingEl.addEventListener(
308
+ "transitionend",
309
+ () => {
310
+ thinkingEl.classList.remove("collapsing");
311
+ thinkingEl.style.maxHeight = "";
312
+ },
313
+ { once: true },
314
+ );
315
+ chevron.textContent = "▼";
316
+ } else {
317
+ thinkingEl.style.maxHeight = thinkingEl.scrollHeight + "px";
318
+ thinkingEl.classList.add("collapsing");
319
+ requestAnimationFrame(() => {
320
+ thinkingEl.classList.add("collapsed");
321
+ });
322
+ thinkingEl.addEventListener(
323
+ "transitionend",
324
+ () => {
325
+ thinkingEl.classList.remove("collapsing");
326
+ thinkingEl.style.maxHeight = "";
327
+ },
328
+ { once: true },
329
+ );
330
+ chevron.textContent = "▶";
331
+ }
332
+ });
333
+ }
334
+
335
+ const streamer = new TextStreamer(processor.tokenizer, {
336
+ skip_prompt: true,
337
+ skip_special_tokens: !enableThinking,
338
+ token_callback_function: () => {
339
+ if (!startTime) startTime = performance.now();
340
+ tokenCount++;
341
+ },
342
+ callback_function: (token) => {
343
+ if (!thinkingDone) {
344
+ // Check if this token contains the </think> boundary
345
+ const endIdx = (fullText + token).indexOf("</think>");
346
+ if (endIdx !== -1) {
347
+ thinkingDone = true;
348
+ const thinkText = (fullText + token).slice(0, endIdx).trim();
349
+ thinkingContentEl.textContent = thinkText;
350
+ fullText = (fullText + token).slice(endIdx + "</think>".length);
351
+ contentEl.textContent = fullText
352
+ .replace(/^\n+/, "")
353
+ .replace(/<\|im_end\|>/g, "");
354
+ // Auto-collapse thinking with animation
355
+ thinkingEl.style.maxHeight = thinkingEl.scrollHeight + "px";
356
+ thinkingEl.classList.add("collapsing");
357
+ requestAnimationFrame(() => {
358
+ thinkingEl.classList.add("collapsed");
359
+ });
360
+ thinkingEl.addEventListener(
361
+ "transitionend",
362
+ () => {
363
+ thinkingEl.classList.remove("collapsing");
364
+ thinkingEl.style.maxHeight = "";
365
+ },
366
+ { once: true },
367
+ );
368
+ chevron.textContent = "▶";
369
+ } else {
370
+ fullText += token;
371
+ thinkingContentEl.textContent = fullText;
372
+ }
373
+ } else {
374
+ fullText += token;
375
+ contentEl.textContent = fullText
376
+ .replace(/^\n+/, "")
377
+ .replace(/<\|im_end\|>/g, "");
378
+ }
379
+ $messages.scrollTop = $messages.scrollHeight;
380
+ },
381
+ });
382
+
383
+ const result = await model.generate({
384
+ ...generateArgs,
385
+ max_new_tokens: enableThinking ? 2048 : 512,
386
+ do_sample: true,
387
+ streamer,
388
+ stopping_criteria: stoppingCriteria,
389
+ return_dict_in_generate: true,
390
+ });
391
+
392
+ // Update past key values for next turn
393
+ pastKeyValues = result.past_key_values;
394
+
395
+ // Decode the full sequence to maintain prompt history
396
+ const fullSequenceText = processor.batch_decode(result.sequences, {
397
+ skip_special_tokens: false,
398
+ })[0];
399
+ promptHistory = fullSequenceText;
400
+
401
+ // Show generation stats
402
+ if (tokenCount > 0 && startTime) {
403
+ const elapsed = (performance.now() - startTime) / 1000;
404
+ const tps = (tokenCount / elapsed).toFixed(1);
405
+ const statsEl = document.createElement("div");
406
+ statsEl.className = "msg-stats";
407
+ statsEl.textContent = `${tokenCount} tokens · ${tps} tok/s · ${elapsed.toFixed(1)}s`;
408
+ assistantEl.appendChild(statsEl);
409
+ }
410
+
411
+ assistantEl.classList.remove("generating");
412
+ } catch (err) {
413
+ console.error(err);
414
+ assistantEl.remove();
415
+ $errBanner.textContent = "Generation error: " + err.message;
416
+ $errBanner.classList.add("visible");
417
+ }
418
+
419
+ isGenerating = false;
420
+ stoppingCriteria.reset();
421
+ updateSendBtn();
422
+ $messages.scrollTop = $messages.scrollHeight;
423
+ }
424
+
425
+ /* ─── Render helpers ─── */
426
+ function appendMessage(role, text, imageDataURL, generating = false) {
427
+ const el = document.createElement("div");
428
+ el.className = `msg ${role}` + (generating ? " generating" : "");
429
+
430
+ const roleEl = document.createElement("div");
431
+ roleEl.className = "msg-role";
432
+ roleEl.textContent = role === "user" ? "You" : "Qwen 3.5";
433
+ el.appendChild(roleEl);
434
+
435
+ if (imageDataURL) {
436
+ const img = document.createElement("img");
437
+ img.className = "msg-image";
438
+ img.src = imageDataURL;
439
+ img.alt = "attached";
440
+ el.appendChild(img);
441
+ }
442
+
443
+ const content = document.createElement("div");
444
+ content.className = "msg-content";
445
+ if (generating) {
446
+ const dots = document.createElement("span");
447
+ dots.className = "thinking-dots";
448
+ for (let i = 0; i < 3; i++)
449
+ dots.appendChild(document.createElement("span"));
450
+ content.appendChild(dots);
451
+ } else {
452
+ content.textContent = text;
453
+ }
454
+ el.appendChild(content);
455
+
456
+ $messages.appendChild(el);
457
+ $messages.scrollTop = $messages.scrollHeight;
458
+ return el;
459
+ }