wop commited on
Commit
e907501
·
verified ·
1 Parent(s): ab9b30e

Update templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +949 -1148
templates/index.html CHANGED
@@ -1,1148 +1,949 @@
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>{{ app_title }}</title>
7
- <style>
8
- :root {
9
- --bg: #0d0f14;
10
- --surface: #13161e;
11
- --surface2: #1a1e2a;
12
- --border: rgba(255,255,255,0.07);
13
- --border2: rgba(255,255,255,0.12);
14
- --text: #e8eaf0;
15
- --text-muted: #7a7f94;
16
- --text-dim: #4a4f64;
17
- --accent: #6d85ff;
18
- --accent2: #a78bfa;
19
- --accent-glow: rgba(109,133,255,0.18);
20
- --human: #34d399;
21
- --human-bg: rgba(52,211,153,0.08);
22
- --danger: #f87171;
23
- --radius: 14px;
24
- --radius-sm: 8px;
25
- --font: 'Sora', sans-serif;
26
- --mono: 'DM Mono', monospace;
27
- --shadow: 0 4px 24px rgba(0,0,0,0.4);
28
- --transition: 200ms cubic-bezier(0.4,0,0.2,1);
29
- }
30
-
31
- @import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Sora:wght@300;400;500;600;700&display=swap');
32
-
33
- * { box-sizing: border-box; margin: 0; padding: 0; }
34
- html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font-family: var(--font); }
35
- body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
36
-
37
- #app {
38
- position: fixed;
39
- inset: 0;
40
- display: flex;
41
- overflow: hidden;
42
- background: var(--bg);
43
- }
44
-
45
- #sidebar {
46
- width: 280px;
47
- min-width: 240px;
48
- background: var(--surface);
49
- border-right: 1px solid var(--border);
50
- display: flex;
51
- flex-direction: column;
52
- overflow: hidden;
53
- }
54
-
55
- #sidebar-header {
56
- padding: 22px 20px 14px;
57
- border-bottom: 1px solid var(--border);
58
- flex-shrink: 0;
59
- }
60
-
61
- #logo {
62
- display: flex;
63
- align-items: center;
64
- gap: 10px;
65
- margin-bottom: 16px;
66
- }
67
-
68
- #logo .icon {
69
- width: 32px;
70
- height: 32px;
71
- background: linear-gradient(135deg, var(--accent), var(--accent2));
72
- border-radius: 9px;
73
- display: flex;
74
- align-items: center;
75
- justify-content: center;
76
- font-size: 16px;
77
- flex-shrink: 0;
78
- box-shadow: 0 0 18px var(--accent-glow);
79
- }
80
-
81
- #logo .text {
82
- font-size: 15px;
83
- font-weight: 600;
84
- letter-spacing: -0.3px;
85
- }
86
-
87
- #logo .text span { color: var(--accent); }
88
-
89
- #new-chat-btn {
90
- width: 100%;
91
- padding: 9px 14px;
92
- background: linear-gradient(135deg, var(--accent), var(--accent2));
93
- border: none;
94
- border-radius: var(--radius-sm);
95
- color: #fff;
96
- font-family: var(--font);
97
- font-size: 13px;
98
- font-weight: 500;
99
- cursor: pointer;
100
- display: flex;
101
- align-items: center;
102
- justify-content: center;
103
- gap: 7px;
104
- box-shadow: 0 2px 12px var(--accent-glow);
105
- }
106
-
107
- #new-chat-btn:hover { opacity: 0.92; }
108
-
109
- #thread-list {
110
- flex: 1;
111
- overflow-y: auto;
112
- padding: 10px 8px;
113
- }
114
-
115
- .thread-item {
116
- padding: 10px 12px;
117
- border-radius: var(--radius-sm);
118
- cursor: pointer;
119
- border: 1px solid transparent;
120
- margin-bottom: 2px;
121
- transition: background var(--transition), border-color var(--transition);
122
- }
123
-
124
- .thread-item:hover { background: var(--surface2); }
125
- .thread-item.active {
126
- background: var(--accent-glow);
127
- border-color: rgba(109,133,255,0.25);
128
- }
129
-
130
- .thread-title {
131
- font-size: 13px;
132
- font-weight: 500;
133
- white-space: nowrap;
134
- overflow: hidden;
135
- text-overflow: ellipsis;
136
- line-height: 1.4;
137
- }
138
-
139
- .thread-meta {
140
- font-size: 11px;
141
- color: var(--text-dim);
142
- margin-top: 3px;
143
- font-family: var(--mono);
144
- }
145
-
146
- #user-bar {
147
- padding: 14px 16px;
148
- border-top: 1px solid var(--border);
149
- display: flex;
150
- align-items: center;
151
- gap: 10px;
152
- flex-shrink: 0;
153
- }
154
-
155
- .avatar {
156
- width: 28px;
157
- height: 28px;
158
- border-radius: 50%;
159
- background: linear-gradient(135deg, var(--accent), var(--accent2));
160
- display: flex;
161
- align-items: center;
162
- justify-content: center;
163
- font-size: 12px;
164
- font-weight: 600;
165
- color: #fff;
166
- flex-shrink: 0;
167
- }
168
-
169
- .user-name {
170
- font-size: 13px;
171
- font-weight: 500;
172
- flex: 1;
173
- overflow: hidden;
174
- text-overflow: ellipsis;
175
- white-space: nowrap;
176
- }
177
-
178
- .user-status {
179
- font-size: 11px;
180
- color: var(--human);
181
- font-family: var(--mono);
182
- }
183
-
184
- #main {
185
- flex: 1;
186
- display: flex;
187
- flex-direction: column;
188
- overflow: hidden;
189
- background: var(--bg);
190
- position: relative;
191
- }
192
-
193
- #main::before {
194
- content: '';
195
- position: absolute;
196
- inset: 0;
197
- background-image:
198
- linear-gradient(rgba(109,133,255,0.03) 1px, transparent 1px),
199
- linear-gradient(90deg, rgba(109,133,255,0.03) 1px, transparent 1px);
200
- background-size: 40px 40px;
201
- pointer-events: none;
202
- }
203
-
204
- #toolbar {
205
- padding: 16px 24px;
206
- border-bottom: 1px solid var(--border);
207
- display: flex;
208
- align-items: center;
209
- justify-content: space-between;
210
- flex-shrink: 0;
211
- backdrop-filter: blur(12px);
212
- background: rgba(13,15,20,0.7);
213
- position: relative;
214
- z-index: 2;
215
- }
216
-
217
- #thread-title {
218
- font-size: 14px;
219
- font-weight: 600;
220
- max-width: 60%;
221
- overflow: hidden;
222
- text-overflow: ellipsis;
223
- white-space: nowrap;
224
- }
225
-
226
- #appearance-wrap {
227
- display: flex;
228
- align-items: center;
229
- gap: 6px;
230
- }
231
-
232
- .appearance-label {
233
- font-size: 11px;
234
- color: var(--text-dim);
235
- font-family: var(--mono);
236
- white-space: nowrap;
237
- }
238
-
239
- #appearance-select {
240
- background: var(--surface2);
241
- border: 1px solid var(--border2);
242
- border-radius: var(--radius-sm);
243
- color: var(--text);
244
- font-family: var(--mono);
245
- font-size: 11px;
246
- padding: 4px 8px;
247
- cursor: pointer;
248
- outline: none;
249
- }
250
-
251
- #messages {
252
- flex: 1;
253
- overflow-y: auto;
254
- padding: 28px 0;
255
- position: relative;
256
- z-index: 1;
257
- }
258
-
259
- .msg-row {
260
- max-width: 760px;
261
- margin: 0 auto 28px;
262
- padding: 0 28px;
263
- }
264
-
265
- .msg-question { display: flex; justify-content: flex-end; }
266
- .msg-question .bubble {
267
- background: linear-gradient(135deg, #1e2480, #2a1b6e);
268
- border: 1px solid rgba(109,133,255,0.3);
269
- border-radius: 18px 18px 4px 18px;
270
- padding: 14px 18px;
271
- max-width: 72%;
272
- font-size: 14px;
273
- line-height: 1.6;
274
- box-shadow: 0 2px 16px rgba(109,133,255,0.12);
275
- }
276
-
277
- .msg-question .meta {
278
- text-align: right;
279
- font-size: 11px;
280
- color: var(--text-dim);
281
- margin-top: 5px;
282
- font-family: var(--mono);
283
- }
284
-
285
- .msg-answer {
286
- display: flex;
287
- gap: 12px;
288
- align-items: flex-start;
289
- }
290
-
291
- .answer-avatar {
292
- width: 30px;
293
- height: 30px;
294
- border-radius: 50%;
295
- background: linear-gradient(135deg, #1e4d38, #16513a);
296
- border: 1px solid rgba(52,211,153,0.3);
297
- display: flex;
298
- align-items: center;
299
- justify-content: center;
300
- font-size: 13px;
301
- flex-shrink: 0;
302
- margin-top: 2px;
303
- }
304
-
305
- .answer-body { flex: 1; min-width: 0; }
306
-
307
- .answer-bubble {
308
- background: var(--surface);
309
- border: 1px solid var(--border);
310
- border-radius: 4px 18px 18px 18px;
311
- padding: 14px 18px;
312
- font-size: 14px;
313
- line-height: 1.7;
314
- position: relative;
315
- }
316
-
317
- .answer-meta {
318
- display: flex;
319
- align-items: center;
320
- gap: 10px;
321
- margin-top: 8px;
322
- font-size: 11px;
323
- color: var(--text-dim);
324
- font-family: var(--mono);
325
- flex-wrap: wrap;
326
- }
327
-
328
- .vote-btn, .propose-btn, .versions-toggle, .propose-submit, .login-hf-btn {
329
- background: none;
330
- border: 1px solid var(--border2);
331
- border-radius: 20px;
332
- color: var(--text-muted);
333
- font-size: 11px;
334
- padding: 2px 9px;
335
- cursor: pointer;
336
- transition: all var(--transition);
337
- font-family: var(--mono);
338
- }
339
-
340
- .vote-btn:hover, .propose-btn:hover, .versions-toggle:hover, .propose-submit:hover {
341
- background: var(--human-bg);
342
- border-color: var(--human);
343
- color: var(--human);
344
- }
345
-
346
- .vote-btn.voted {
347
- background: var(--human-bg);
348
- border-color: var(--human);
349
- color: var(--human);
350
- }
351
-
352
- .versions-toggle {
353
- padding: 4px 0;
354
- border: none;
355
- margin-top: 6px;
356
- display: flex;
357
- align-items: center;
358
- gap: 5px;
359
- color: var(--text-dim);
360
- }
361
-
362
- .versions-panel {
363
- margin-top: 10px;
364
- border-left: 2px solid var(--border2);
365
- padding-left: 14px;
366
- display: none;
367
- }
368
-
369
- .versions-panel.open { display: block; }
370
-
371
- .version-card {
372
- background: var(--surface2);
373
- border: 1px solid var(--border);
374
- border-radius: var(--radius-sm);
375
- padding: 12px 14px;
376
- margin-bottom: 8px;
377
- font-size: 13px;
378
- line-height: 1.65;
379
- }
380
-
381
- .version-header {
382
- display: flex;
383
- align-items: center;
384
- gap: 8px;
385
- margin-bottom: 8px;
386
- font-size: 11px;
387
- color: var(--text-dim);
388
- font-family: var(--mono);
389
- flex-wrap: wrap;
390
- }
391
-
392
- .propose-form { display: none; margin-top: 10px; }
393
- .propose-form.open { display: block; }
394
-
395
- .propose-textarea {
396
- width: 100%;
397
- background: var(--surface2);
398
- border: 1px solid var(--border2);
399
- border-radius: var(--radius-sm);
400
- color: var(--text);
401
- font-family: var(--font);
402
- font-size: 13px;
403
- line-height: 1.6;
404
- padding: 10px 13px;
405
- resize: vertical;
406
- min-height: 80px;
407
- outline: none;
408
- }
409
-
410
- .propose-textarea:focus { border-color: var(--accent); }
411
-
412
- #welcome {
413
- position: absolute;
414
- inset: 0;
415
- display: flex;
416
- flex-direction: column;
417
- align-items: center;
418
- justify-content: center;
419
- gap: 18px;
420
- text-align: center;
421
- padding: 40px;
422
- pointer-events: none;
423
- }
424
-
425
- #welcome.hidden { display: none; }
426
-
427
- .welcome-glyph {
428
- font-size: 48px;
429
- filter: drop-shadow(0 0 24px var(--accent-glow));
430
- }
431
-
432
- .welcome-title {
433
- font-size: 26px;
434
- font-weight: 600;
435
- letter-spacing: -0.5px;
436
- background: linear-gradient(135deg, var(--text), var(--accent));
437
- -webkit-background-clip: text;
438
- -webkit-text-fill-color: transparent;
439
- background-clip: text;
440
- }
441
-
442
- .welcome-sub {
443
- font-size: 14px;
444
- color: var(--text-muted);
445
- max-width: 340px;
446
- line-height: 1.65;
447
- }
448
-
449
- #composer-wrap {
450
- padding: 16px 24px 20px;
451
- border-top: 1px solid var(--border);
452
- flex-shrink: 0;
453
- backdrop-filter: blur(12px);
454
- background: rgba(13,15,20,0.85);
455
- position: relative;
456
- z-index: 2;
457
- }
458
-
459
- #composer {
460
- display: flex;
461
- align-items: flex-end;
462
- gap: 10px;
463
- background: var(--surface);
464
- border: 1px solid var(--border2);
465
- border-radius: 16px;
466
- padding: 12px 14px 10px;
467
- transition: border-color var(--transition), box-shadow var(--transition);
468
- }
469
-
470
- #composer:focus-within {
471
- border-color: var(--accent);
472
- box-shadow: 0 0 0 3px var(--accent-glow);
473
- }
474
-
475
- #input {
476
- flex: 1;
477
- background: none;
478
- border: none;
479
- outline: none;
480
- color: var(--text);
481
- font-family: var(--font);
482
- font-size: 14px;
483
- line-height: 1.6;
484
- resize: none;
485
- max-height: 200px;
486
- scrollbar-width: thin;
487
- scrollbar-color: var(--border2) transparent;
488
- }
489
-
490
- #input::placeholder { color: var(--text-dim); }
491
-
492
- #send {
493
- width: 36px;
494
- height: 36px;
495
- background: linear-gradient(135deg, var(--accent), var(--accent2));
496
- border: none;
497
- border-radius: 10px;
498
- cursor: pointer;
499
- display: flex;
500
- align-items: center;
501
- justify-content: center;
502
- flex-shrink: 0;
503
- box-shadow: 0 2px 10px var(--accent-glow);
504
- }
505
-
506
- #send:hover { opacity: 0.85; }
507
- #send:disabled { opacity: 0.35; cursor: not-allowed; }
508
- #send svg { width: 16px; height: 16px; fill: #fff; }
509
-
510
- .hint {
511
- text-align: center;
512
- font-size: 11px;
513
- color: var(--text-dim);
514
- font-family: var(--mono);
515
- margin-top: 8px;
516
- }
517
-
518
- #toast {
519
- position: fixed;
520
- bottom: 90px;
521
- left: 50%;
522
- transform: translateX(-50%) translateY(20px);
523
- background: var(--surface2);
524
- border: 1px solid var(--border2);
525
- border-radius: 30px;
526
- padding: 9px 20px;
527
- font-size: 13px;
528
- color: var(--text);
529
- font-family: var(--mono);
530
- opacity: 0;
531
- transition: opacity 250ms, transform 250ms;
532
- pointer-events: none;
533
- z-index: 1000;
534
- white-space: nowrap;
535
- }
536
-
537
- #toast.show {
538
- opacity: 1;
539
- transform: translateX(-50%) translateY(0);
540
- }
541
-
542
- #toast.error { border-color: var(--danger); color: var(--danger); }
543
- #toast.success { border-color: var(--human); color: var(--human); }
544
-
545
- #login-overlay {
546
- position: fixed;
547
- inset: 0;
548
- background: rgba(0,0,0,0.7);
549
- display: flex;
550
- align-items: center;
551
- justify-content: center;
552
- z-index: 500;
553
- backdrop-filter: blur(6px);
554
- }
555
-
556
- #login-overlay.hidden { display: none; }
557
-
558
- .login-card {
559
- background: var(--surface);
560
- border: 1px solid var(--border2);
561
- border-radius: var(--radius);
562
- padding: 40px 48px;
563
- text-align: center;
564
- max-width: 380px;
565
- width: 90%;
566
- box-shadow: var(--shadow);
567
- }
568
-
569
- .login-card h2 {
570
- font-size: 22px;
571
- font-weight: 600;
572
- margin-bottom: 8px;
573
- letter-spacing: -0.3px;
574
- }
575
-
576
- .login-card p {
577
- font-size: 13px;
578
- color: var(--text-muted);
579
- margin-bottom: 22px;
580
- line-height: 1.6;
581
- }
582
-
583
- .login-row {
584
- display: flex;
585
- gap: 10px;
586
- flex-direction: column;
587
- }
588
-
589
- .login-input {
590
- width: 100%;
591
- background: var(--surface2);
592
- border: 1px solid var(--border2);
593
- border-radius: var(--radius-sm);
594
- color: var(--text);
595
- font-family: var(--font);
596
- font-size: 14px;
597
- padding: 11px 13px;
598
- outline: none;
599
- }
600
-
601
- .login-input:focus { border-color: var(--accent); }
602
-
603
- .login-hf-btn {
604
- display: inline-flex;
605
- align-items: center;
606
- justify-content: center;
607
- gap: 10px;
608
- padding: 12px 24px;
609
- background: #ff9d00;
610
- border: none;
611
- border-radius: var(--radius-sm);
612
- color: #000;
613
- font-size: 14px;
614
- font-weight: 600;
615
- text-decoration: none;
616
- }
617
-
618
- .spacer { height: 8px; }
619
-
620
- @media (max-width: 680px) {
621
- #sidebar { width: 60px; min-width: 60px; }
622
- #sidebar-header .text, .thread-title, .thread-meta, #user-bar .user-name, #user-bar .user-status { display: none; }
623
- #new-chat-btn span { display: none; }
624
- .msg-row { padding: 0 12px; }
625
- }
626
- </style>
627
- </head>
628
- <body>
629
- <div id="login-overlay">
630
- <div class="login-card">
631
- <div style="font-size:36px;margin-bottom:12px">🧠</div>
632
- <h2>Human Intelligence</h2>
633
- <p>Real people write answers. Versions are voted, and the best version is shown first.</p>
634
- <div class="login-row">
635
- <input id="username" class="login-input" placeholder="Your name" />
636
- <button class="login-hf-btn" id="set-username-btn">Enter</button>
637
- </div>
638
- <div class="spacer"></div>
639
- <p style="margin-bottom:12px;">This demo uses a local username. Replace it with your auth later.</p>
640
- </div>
641
- </div>
642
-
643
- <div id="app">
644
- <div id="sidebar">
645
- <div id="sidebar-header">
646
- <div id="logo">
647
- <div class="icon">🧠</div>
648
- <div class="text">Human <span>Intelligence</span></div>
649
- </div>
650
- <button id="new-chat-btn">
651
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
652
- <line x1="12" y1="5" x2="12" y2="19"></line>
653
- <line x1="5" y1="12" x2="19" y2="12"></line>
654
- </svg>
655
- <span>New question</span>
656
- </button>
657
- </div>
658
-
659
- <div id="thread-list"></div>
660
-
661
- <div id="user-bar">
662
- <div class="avatar" id="avatar">?</div>
663
- <div>
664
- <div class="user-name" id="user-name">Guest</div>
665
- <div class="user-status" id="user-status">not signed in</div>
666
- </div>
667
- </div>
668
- </div>
669
-
670
- <div id="main">
671
- <div id="toolbar">
672
- <div id="thread-title">Human Intelligence</div>
673
- <div id="appearance-wrap">
674
- <span class="appearance-label">appearance</span>
675
- <select id="appearance-select">
676
- <option value="none">None</option>
677
- <option value="ai">AI typing</option>
678
- <option value="human">Human typing</option>
679
- <option value="diffusion">Diffusion</option>
680
- </select>
681
- </div>
682
- </div>
683
-
684
- <div id="messages"></div>
685
-
686
- <div id="welcome">
687
- <div class="welcome-glyph">🧠</div>
688
- <div class="welcome-title">Ask a human anything</div>
689
- <div class="welcome-sub">
690
- Start a new question or pick a conversation from the left.
691
- Real people answer — voted, versioned, and honest.
692
- </div>
693
- </div>
694
-
695
- <div id="composer-wrap">
696
- <div id="composer">
697
- <textarea id="input" rows="1" placeholder="Ask a question…"></textarea>
698
- <button id="send">
699
- <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
700
- <path d="M22 2L11 13M22 2L15 22 11 13 2 9l20-7z"></path>
701
- </svg>
702
- </button>
703
- </div>
704
- <div class="hint">A human expert will answer · Press Enter to send · Shift+Enter for newline</div>
705
- </div>
706
- </div>
707
- </div>
708
-
709
- <div id="toast"></div>
710
-
711
- <script>
712
- window.__HI_INIT__ = {{ init_json | safe }};
713
- </script>
714
-
715
- <script>
716
- (() => {
717
- const S = {
718
- activeThread: null,
719
- appearance: 'none',
720
- username: null,
721
- };
722
-
723
- const el = (id) => document.getElementById(id);
724
-
725
- function toast(msg, type='') {
726
- const t = el('toast');
727
- if (!t) return;
728
- t.textContent = msg;
729
- t.className = 'show ' + type;
730
- clearTimeout(t._t);
731
- t._t = setTimeout(() => { t.className = ''; }, 2600);
732
- }
733
-
734
- function escHtml(s) {
735
- return String(s)
736
- .replace(/&/g, '&amp;')
737
- .replace(/</g, '&lt;')
738
- .replace(/>/g, '&gt;')
739
- .replace(/"/g, '&quot;')
740
- .replace(/\\n/g, '<br>');
741
- }
742
-
743
- function fmtDate(iso) {
744
- try {
745
- return new Date(iso).toLocaleString([], {
746
- month: 'short',
747
- day: 'numeric',
748
- hour: '2-digit',
749
- minute: '2-digit',
750
- });
751
- } catch {
752
- return '';
753
- }
754
- }
755
-
756
- function autoGrow(node) {
757
- node.style.height = 'auto';
758
- node.style.height = Math.min(node.scrollHeight, 200) + 'px';
759
- }
760
-
761
- function scrollToBottom() {
762
- const m = el('messages');
763
- if (m) m.scrollTop = m.scrollHeight;
764
- }
765
-
766
- function setIdentity(name) {
767
- const username = (name || '').trim();
768
- if (!username) return;
769
-
770
- S.username = username;
771
- localStorage.setItem('hi_username', username);
772
-
773
- el('user-name').textContent = username;
774
- el('user-status').textContent = 'signed in';
775
- el('avatar').textContent = username[0].toUpperCase();
776
-
777
- const overlay = el('login-overlay');
778
- if (overlay) overlay.classList.add('hidden');
779
-
780
- fetchInit();
781
- }
782
-
783
- function applyAppearance(node) {
784
- if (!node) return;
785
- const mode = S.appearance;
786
- node.classList.remove('typing-cursor', 'diffuse-reveal');
787
-
788
- if (mode === 'diffusion') {
789
- node.classList.add('diffuse-reveal');
790
- return;
791
- }
792
-
793
- if (mode === 'ai' || mode === 'human') {
794
- const source = node.innerHTML;
795
- node.innerHTML = '';
796
- node.classList.add('typing-cursor');
797
-
798
- const chunks = mode === 'ai'
799
- ? [...source]
800
- : source.split(/(\\s+|<br>)/);
801
-
802
- let i = 0;
803
- const tick = () => {
804
- if (i < chunks.length) {
805
- node.innerHTML += chunks[i++];
806
- setTimeout(tick, mode === 'ai' ? 18 + Math.random() * 10 : 60 + Math.random() * 90);
807
- } else {
808
- node.classList.remove('typing-cursor');
809
- }
810
- };
811
- tick();
812
- }
813
- }
814
-
815
- function renderThreadList(threads) {
816
- const list = el('thread-list');
817
- if (!list) return;
818
-
819
- list.innerHTML = '';
820
- if (!threads || !threads.length) {
821
- list.innerHTML = '<div style="padding:16px 12px;font-size:12px;color:var(--text-dim);font-family:var(--mono);">No conversations yet</div>';
822
- return;
823
- }
824
-
825
- threads.forEach(t => {
826
- const div = document.createElement('div');
827
- div.className = 'thread-item' + (S.activeThread && S.activeThread.id === t.id ? ' active' : '');
828
- div.dataset.id = t.id;
829
- div.innerHTML = `
830
- <div class="thread-title">${escHtml(t.title)}</div>
831
- <div class="thread-meta">${t.reply_count} answers · ${fmtDate(t.created_at)}</div>
832
- `;
833
- div.addEventListener('click', () => loadThread(t.id));
834
- list.appendChild(div);
835
- });
836
- }
837
-
838
- function buildAnswerRow(msg) {
839
- const av = msg.versions.find(v => v.id === msg.active_version) || msg.versions[0];
840
- if (!av) return '';
841
-
842
- const hasOthers = msg.versions.length > 1;
843
- const othersHTML = hasOthers
844
- ? msg.versions
845
- .filter(v => v.id !== av.id)
846
- .map(v => `
847
- <div class="version-card">
848
- <div class="version-header">
849
- <span>${escHtml(v.author)}</span>
850
- <span>${fmtDate(v.created_at)}</span>
851
- <span>▲ ${v.votes}</span>
852
- <button class="vote-btn${v.voters && v.voters.includes(S.username) ? ' voted' : ''}"
853
- onclick="voteVersion('${msg.id}', '${v.id}', this)">▲ Upvote</button>
854
- </div>
855
- <div>${escHtml(v.text)}</div>
856
- </div>
857
- `).join('')
858
- : '';
859
-
860
- const versionsSection = hasOthers
861
- ? `
862
- <button class="versions-toggle" onclick="toggleVersions(this)">
863
- <span class="arrow">▶</span> ${msg.versions.length - 1} other version${msg.versions.length > 2 ? 's' : ''}
864
- </button>
865
- <div class="versions-panel">${othersHTML}</div>
866
- `
867
- : '';
868
-
869
- return `
870
- <div class="msg-row" data-msg-id="${msg.id}">
871
- <div class="msg-answer">
872
- <div class="answer-avatar">🧑</div>
873
- <div class="answer-body">
874
- <div class="answer-bubble" id="bubble-${msg.id}">
875
- <div class="answer-text" id="atext-${msg.id}">${escHtml(av.text)}</div>
876
- </div>
877
- <div class="answer-meta">
878
- <span>${escHtml(av.author)}</span>
879
- <span>${fmtDate(av.created_at)}</span>
880
- <button class="vote-btn${av.voters && av.voters.includes(S.username) ? ' voted' : ''}"
881
- onclick="voteVersion('${msg.id}', '${av.id}', this)">▲ ${av.votes}</button>
882
- <button class="propose-btn" onclick="togglePropose('${msg.id}', this)">✏ Propose edit</button>
883
- </div>
884
- ${versionsSection}
885
- <div class="propose-form" id="propose-${msg.id}">
886
- <textarea class="propose-textarea" placeholder="Propose an improved answer…"></textarea>
887
- <button class="propose-submit" onclick="submitPropose('${msg.id}', this)">Submit version</button>
888
- </div>
889
- </div>
890
- </div>
891
- </div>
892
- `;
893
- }
894
-
895
- function renderThread(thread) {
896
- S.activeThread = thread;
897
- el('welcome').classList.add('hidden');
898
- el('thread-title').textContent = thread.question;
899
-
900
- const area = el('messages');
901
- area.innerHTML = '';
902
-
903
- const qRow = document.createElement('div');
904
- qRow.className = 'msg-row';
905
- qRow.innerHTML = `
906
- <div class="msg-question">
907
- <div>
908
- <div class="bubble">${escHtml(thread.question)}</div>
909
- <div class="meta">${escHtml(thread.author)} · ${fmtDate(thread.created_at)}</div>
910
- </div>
911
- </div>
912
- `;
913
- area.appendChild(qRow);
914
-
915
- (thread.messages || []).forEach(msg => {
916
- const wrap = document.createElement('div');
917
- wrap.innerHTML = buildAnswerRow(msg);
918
- area.appendChild(wrap.firstElementChild);
919
- });
920
-
921
- document.querySelectorAll('.thread-item').forEach(node => {
922
- node.classList.toggle('active', node.dataset.id === thread.id);
923
- });
924
-
925
- scrollToBottom();
926
- }
927
-
928
- async function callBackend(action, payload={}) {
929
- const username = S.username || localStorage.getItem('hi_username') || '';
930
- const body = { action, username, ...payload };
931
-
932
- const resp = await fetch('/api', {
933
- method: 'POST',
934
- headers: {
935
- 'Content-Type': 'application/json',
936
- 'X-User': username,
937
- },
938
- body: JSON.stringify(body),
939
- });
940
-
941
- return await resp.json();
942
- }
943
-
944
- async function fetchInit() {
945
- const res = await callBackend('init', {});
946
- if (res.ok) {
947
- S.username = res.username || S.username || localStorage.getItem('hi_username') || null;
948
- renderThreadList(res.threads || []);
949
-
950
- if (S.username && S.username !== 'Guest') {
951
- el('user-name').textContent = S.username;
952
- el('user-status').textContent = 'signed in';
953
- el('avatar').textContent = S.username[0].toUpperCase();
954
- el('login-overlay').classList.add('hidden');
955
- }
956
- }
957
- }
958
-
959
- async function loadThread(tid) {
960
- const res = await callBackend('get_thread', { thread_id: tid });
961
- if (res.ok && res.thread) {
962
- renderThread(res.thread);
963
- } else {
964
- toast('Could not load thread', 'error');
965
- }
966
- }
967
-
968
- async function startNewChat() {
969
- S.activeThread = null;
970
- document.querySelectorAll('.thread-item').forEach(elm => elm.classList.remove('active'));
971
- el('thread-title').textContent = 'New conversation';
972
- el('messages').innerHTML = '';
973
- el('welcome').classList.remove('hidden');
974
- el('input').placeholder = 'Ask a question…';
975
- el('input').focus();
976
- }
977
-
978
- window.toggleVersions = function(btn) {
979
- const panel = btn.nextElementSibling;
980
- btn.classList.toggle('open');
981
- if (panel) panel.classList.toggle('open');
982
- };
983
-
984
- window.togglePropose = function(msgId, btn) {
985
- const form = document.getElementById('propose-' + msgId);
986
- if (form) form.classList.toggle('open');
987
- };
988
-
989
- window.voteVersion = async function(msgId, versionId, btn) {
990
- if (!S.username) { toast('Sign in to vote', 'error'); return; }
991
- if (!S.activeThread) return;
992
- if (btn.classList.contains('voted')) { toast('Already voted'); return; }
993
-
994
- const res = await callBackend('vote', {
995
- thread_id: S.activeThread.id,
996
- msg_id: msgId,
997
- version_id: versionId,
998
- });
999
-
1000
- if (res.ok) {
1001
- S.activeThread = res.thread;
1002
- renderThread(res.thread);
1003
- toast('Voted!', 'success');
1004
- } else {
1005
- toast(res.error || 'Error', 'error');
1006
- }
1007
- };
1008
-
1009
- window.submitPropose = async function(msgId, btn) {
1010
- if (!S.username) { toast('Sign in first', 'error'); return; }
1011
- if (!S.activeThread) return;
1012
-
1013
- const form = document.getElementById('propose-' + msgId);
1014
- const ta = form ? form.querySelector('textarea') : null;
1015
- const text = ta ? ta.value.trim() : '';
1016
- if (!text) { toast('Empty proposal', 'error'); return; }
1017
-
1018
- btn.disabled = true;
1019
- btn.textContent = 'Submitting…';
1020
-
1021
- const res = await callBackend('propose', {
1022
- thread_id: S.activeThread.id,
1023
- msg_id: msgId,
1024
- text,
1025
- });
1026
-
1027
- btn.disabled = false;
1028
- btn.textContent = 'Submit version';
1029
-
1030
- if (res.ok) {
1031
- S.activeThread = res.thread;
1032
- renderThread(res.thread);
1033
- if (form) form.classList.remove('open');
1034
- if (ta) ta.value = '';
1035
- toast('Version proposed!', 'success');
1036
- } else {
1037
- toast(res.error || 'Error', 'error');
1038
- }
1039
- };
1040
-
1041
- async function sendMessage() {
1042
- if (!S.username) { toast('Enter a username first', 'error'); return; }
1043
-
1044
- const input = el('input');
1045
- const text = input.value.trim();
1046
- if (!text) return;
1047
-
1048
- input.value = '';
1049
- autoGrow(input);
1050
-
1051
- const sendBtn = el('send');
1052
- sendBtn.disabled = true;
1053
-
1054
- if (!S.activeThread) {
1055
- const res = await callBackend('new_thread', { question: text });
1056
- sendBtn.disabled = false;
1057
- if (res.ok) {
1058
- S.activeThread = res.thread;
1059
- const listRes = await callBackend('list_threads', {});
1060
- if (listRes.ok) renderThreadList(listRes.threads);
1061
- renderThread(res.thread);
1062
- if (res.similar) toast('Similar thread exists', '');
1063
- } else {
1064
- toast(res.error || 'Error', 'error');
1065
- }
1066
- } else {
1067
- const res = await callBackend('add_answer', {
1068
- thread_id: S.activeThread.id,
1069
- text,
1070
- });
1071
- sendBtn.disabled = false;
1072
- if (res.ok) {
1073
- S.activeThread = res.thread;
1074
- const msgs = res.thread.messages;
1075
- const lastMsg = msgs[msgs.length - 1];
1076
- const area = el('messages');
1077
- const wrap = document.createElement('div');
1078
- wrap.innerHTML = buildAnswerRow(lastMsg);
1079
- const row = wrap.firstElementChild;
1080
- area.appendChild(row);
1081
-
1082
- const textEl = row.querySelector('.answer-text');
1083
- applyAppearance(textEl);
1084
-
1085
- const listRes = await callBackend('list_threads', {});
1086
- if (listRes.ok) renderThreadList(listRes.threads);
1087
-
1088
- scrollToBottom();
1089
- toast('Answer posted!', 'success');
1090
- } else {
1091
- toast(res.error || 'Error', 'error');
1092
- }
1093
- }
1094
- }
1095
-
1096
- function initEvents() {
1097
- el('set-username-btn').addEventListener('click', () => {
1098
- setIdentity(el('username').value);
1099
- });
1100
-
1101
- el('username').addEventListener('keydown', (e) => {
1102
- if (e.key === 'Enter') setIdentity(e.target.value);
1103
- });
1104
-
1105
- el('new-chat-btn').addEventListener('click', startNewChat);
1106
-
1107
- el('appearance-select').addEventListener('change', (e) => {
1108
- S.appearance = e.target.value;
1109
- });
1110
-
1111
- const input = el('input');
1112
- input.addEventListener('input', () => autoGrow(input));
1113
- input.addEventListener('keydown', (e) => {
1114
- if (e.key === 'Enter' && !e.shiftKey) {
1115
- e.preventDefault();
1116
- sendMessage();
1117
- }
1118
- });
1119
-
1120
- el('send').addEventListener('click', sendMessage);
1121
-
1122
- const stored = localStorage.getItem('hi_username');
1123
- if (stored && stored.trim()) {
1124
- setIdentity(stored);
1125
- } else {
1126
- el('username').focus();
1127
- }
1128
- }
1129
-
1130
- function boot() {
1131
- const init = window.__HI_INIT__ || {};
1132
- if (init.username && init.username !== 'Guest') {
1133
- S.username = init.username;
1134
- localStorage.setItem('hi_username', init.username);
1135
- el('user-name').textContent = init.username;
1136
- el('user-status').textContent = 'signed in';
1137
- el('avatar').textContent = init.username[0].toUpperCase();
1138
- el('login-overlay').classList.add('hidden');
1139
- }
1140
- renderThreadList(init.threads || []);
1141
- initEvents();
1142
- }
1143
-
1144
- boot();
1145
- })();
1146
- </script>
1147
- </body>
1148
- </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>{{ app_title }}</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b0e14;
10
+ --panel: #11151d;
11
+ --panel2: #171c26;
12
+ --border: rgba(255,255,255,.08);
13
+ --border2: rgba(255,255,255,.13);
14
+ --text: #ebeff7;
15
+ --muted: #8b93a8;
16
+ --accent: #6c83ff;
17
+ --accent2: #a16eff;
18
+ --good: #2dd4bf;
19
+ --bad: #f87171;
20
+ --shadow: 0 20px 60px rgba(0,0,0,.35);
21
+ --r: 18px;
22
+ --r2: 12px;
23
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
24
+ --font: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ }
26
+
27
+ * { box-sizing: border-box; }
28
+ html, body {
29
+ width: 100%;
30
+ height: 100%;
31
+ margin: 0;
32
+ background: radial-gradient(circle at top, #161c2b 0%, var(--bg) 46%);
33
+ color: var(--text);
34
+ font-family: var(--font);
35
+ overflow: hidden;
36
+ }
37
+
38
+ body::before {
39
+ content: "";
40
+ position: fixed;
41
+ inset: 0;
42
+ background-image:
43
+ linear-gradient(rgba(108,131,255,.04) 1px, transparent 1px),
44
+ linear-gradient(90deg, rgba(108,131,255,.04) 1px, transparent 1px);
45
+ background-size: 44px 44px;
46
+ pointer-events: none;
47
+ opacity: .35;
48
+ }
49
+
50
+ #app {
51
+ position: relative;
52
+ z-index: 1;
53
+ height: 100%;
54
+ display: flex;
55
+ flex-direction: column;
56
+ }
57
+
58
+ #topbar {
59
+ height: 72px;
60
+ padding: 0 22px;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ border-bottom: 1px solid var(--border);
65
+ backdrop-filter: blur(12px);
66
+ background: rgba(11,14,20,.72);
67
+ }
68
+
69
+ .brand {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 12px;
73
+ min-width: 0;
74
+ }
75
+
76
+ .logo {
77
+ width: 36px;
78
+ height: 36px;
79
+ border-radius: 12px;
80
+ display: grid;
81
+ place-items: center;
82
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
83
+ box-shadow: 0 10px 24px rgba(108,131,255,.25);
84
+ flex: 0 0 auto;
85
+ }
86
+
87
+ .brand-title {
88
+ font-weight: 800;
89
+ letter-spacing: -.03em;
90
+ font-size: 18px;
91
+ }
92
+
93
+ .brand-sub {
94
+ margin-top: 2px;
95
+ color: var(--muted);
96
+ font-size: 12px;
97
+ font-family: var(--mono);
98
+ }
99
+
100
+ .top-actions {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 10px;
104
+ }
105
+
106
+ .pill {
107
+ border: 1px solid var(--border);
108
+ background: rgba(255,255,255,.03);
109
+ color: var(--text);
110
+ border-radius: 999px;
111
+ padding: 8px 12px;
112
+ font-size: 12px;
113
+ font-family: var(--mono);
114
+ }
115
+
116
+ .button {
117
+ border: 1px solid transparent;
118
+ border-radius: 12px;
119
+ padding: 10px 14px;
120
+ cursor: pointer;
121
+ font: inherit;
122
+ color: white;
123
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
124
+ box-shadow: 0 10px 26px rgba(108,131,255,.2);
125
+ }
126
+
127
+ .button.secondary {
128
+ background: rgba(255,255,255,.04);
129
+ border-color: var(--border);
130
+ box-shadow: none;
131
+ color: var(--text);
132
+ }
133
+
134
+ #chat {
135
+ flex: 1;
136
+ overflow-y: auto;
137
+ padding: 26px 18px 30px;
138
+ }
139
+
140
+ .wrap {
141
+ max-width: 980px;
142
+ margin: 0 auto;
143
+ }
144
+
145
+ .welcome {
146
+ margin: 8vh auto 0;
147
+ max-width: 560px;
148
+ text-align: center;
149
+ padding: 28px 24px;
150
+ border: 1px solid var(--border);
151
+ border-radius: 24px;
152
+ background: rgba(255,255,255,.03);
153
+ box-shadow: var(--shadow);
154
+ }
155
+
156
+ .welcome h1 {
157
+ margin: 0;
158
+ font-size: 28px;
159
+ letter-spacing: -.04em;
160
+ }
161
+
162
+ .welcome p {
163
+ color: var(--muted);
164
+ line-height: 1.7;
165
+ margin: 12px 0 0;
166
+ }
167
+
168
+ .turn {
169
+ display: flex;
170
+ gap: 12px;
171
+ margin: 0 0 18px;
172
+ align-items: flex-start;
173
+ }
174
+
175
+ .turn.user {
176
+ justify-content: flex-end;
177
+ }
178
+
179
+ .avatar {
180
+ width: 34px;
181
+ height: 34px;
182
+ border-radius: 50%;
183
+ display: grid;
184
+ place-items: center;
185
+ font-size: 14px;
186
+ flex: 0 0 auto;
187
+ }
188
+
189
+ .avatar.user {
190
+ background: linear-gradient(135deg, #1f2b63, #2d1d58);
191
+ border: 1px solid rgba(108,131,255,.25);
192
+ }
193
+
194
+ .avatar.assistant {
195
+ background: linear-gradient(135deg, #163d34, #183c54);
196
+ border: 1px solid rgba(45,212,191,.25);
197
+ }
198
+
199
+ .bubble {
200
+ max-width: min(760px, calc(100vw - 120px));
201
+ border: 1px solid var(--border);
202
+ border-radius: 18px;
203
+ padding: 14px 16px;
204
+ line-height: 1.65;
205
+ white-space: pre-wrap;
206
+ word-break: break-word;
207
+ background: rgba(255,255,255,.03);
208
+ }
209
+
210
+ .turn.user .bubble {
211
+ background: linear-gradient(135deg, rgba(108,131,255,.18), rgba(161,110,255,.16));
212
+ border-color: rgba(108,131,255,.24);
213
+ border-radius: 18px 18px 6px 18px;
214
+ box-shadow: 0 10px 28px rgba(108,131,255,.1);
215
+ }
216
+
217
+ .turn.assistant .bubble {
218
+ background: rgba(255,255,255,.035);
219
+ border-radius: 6px 18px 18px 18px;
220
+ }
221
+
222
+ .meta {
223
+ margin-top: 6px;
224
+ font-size: 11px;
225
+ color: var(--muted);
226
+ font-family: var(--mono);
227
+ display: flex;
228
+ flex-wrap: wrap;
229
+ gap: 8px;
230
+ align-items: center;
231
+ }
232
+
233
+ .chip {
234
+ border: 1px solid var(--border);
235
+ border-radius: 999px;
236
+ padding: 3px 8px;
237
+ font-size: 10px;
238
+ text-transform: uppercase;
239
+ letter-spacing: .06em;
240
+ }
241
+
242
+ .chip.good { color: var(--good); }
243
+ .chip.muted { color: var(--muted); }
244
+ .chip.bad { color: var(--bad); }
245
+
246
+ .answer-card {
247
+ margin-top: 10px;
248
+ padding: 14px;
249
+ border: 1px solid var(--border);
250
+ border-radius: 18px;
251
+ background: rgba(255,255,255,.03);
252
+ }
253
+
254
+ .answer-top {
255
+ display: flex;
256
+ gap: 12px;
257
+ align-items: flex-start;
258
+ }
259
+
260
+ .answer-main {
261
+ flex: 1;
262
+ min-width: 0;
263
+ }
264
+
265
+ .answer-head {
266
+ display: flex;
267
+ gap: 8px;
268
+ flex-wrap: wrap;
269
+ align-items: center;
270
+ margin-bottom: 10px;
271
+ color: var(--muted);
272
+ font-family: var(--mono);
273
+ font-size: 11px;
274
+ }
275
+
276
+ .answer-text {
277
+ white-space: pre-wrap;
278
+ word-break: break-word;
279
+ line-height: 1.7;
280
+ font-size: 14px;
281
+ }
282
+
283
+ .actions {
284
+ display: flex;
285
+ gap: 8px;
286
+ flex-wrap: wrap;
287
+ margin-top: 12px;
288
+ }
289
+
290
+ .mini {
291
+ border: 1px solid var(--border2);
292
+ background: rgba(255,255,255,.02);
293
+ color: var(--text);
294
+ border-radius: 999px;
295
+ padding: 7px 10px;
296
+ font: inherit;
297
+ font-size: 12px;
298
+ cursor: pointer;
299
+ }
300
+
301
+ .mini:hover {
302
+ border-color: rgba(108,131,255,.4);
303
+ background: rgba(108,131,255,.08);
304
+ }
305
+
306
+ .mini.voted-up {
307
+ border-color: rgba(45,212,191,.6);
308
+ color: var(--good);
309
+ }
310
+
311
+ .mini.voted-down {
312
+ border-color: rgba(248,113,113,.6);
313
+ color: var(--bad);
314
+ }
315
+
316
+ .versions {
317
+ margin-top: 12px;
318
+ border-left: 2px solid var(--border2);
319
+ padding-left: 12px;
320
+ display: none;
321
+ }
322
+
323
+ .versions.open {
324
+ display: block;
325
+ }
326
+
327
+ .version {
328
+ margin-top: 10px;
329
+ border: 1px solid var(--border);
330
+ background: rgba(255,255,255,.025);
331
+ border-radius: 14px;
332
+ padding: 12px;
333
+ }
334
+
335
+ .version .meta {
336
+ margin-top: 0;
337
+ margin-bottom: 8px;
338
+ }
339
+
340
+ .compose {
341
+ border-top: 1px solid var(--border);
342
+ background: rgba(11,14,20,.84);
343
+ backdrop-filter: blur(14px);
344
+ padding: 16px 18px 18px;
345
+ }
346
+
347
+ .compose-inner {
348
+ max-width: 980px;
349
+ margin: 0 auto;
350
+ border: 1px solid var(--border2);
351
+ border-radius: 18px;
352
+ padding: 12px 12px 10px;
353
+ background: var(--panel);
354
+ box-shadow: var(--shadow);
355
+ }
356
+
357
+ #prompt {
358
+ width: 100%;
359
+ min-height: 56px;
360
+ max-height: 240px;
361
+ resize: vertical;
362
+ border: none;
363
+ outline: none;
364
+ background: transparent;
365
+ color: var(--text);
366
+ font: inherit;
367
+ line-height: 1.65;
368
+ padding: 2px 2px 8px;
369
+ white-space: pre-wrap;
370
+ }
371
+
372
+ #prompt::placeholder {
373
+ color: #6f768a;
374
+ }
375
+
376
+ .compose-row {
377
+ display: flex;
378
+ align-items: center;
379
+ justify-content: space-between;
380
+ gap: 12px;
381
+ border-top: 1px solid var(--border);
382
+ padding-top: 10px;
383
+ }
384
+
385
+ .hint {
386
+ color: var(--muted);
387
+ font-size: 11px;
388
+ font-family: var(--mono);
389
+ }
390
+
391
+ .send {
392
+ min-width: 102px;
393
+ }
394
+
395
+ #toast {
396
+ position: fixed;
397
+ left: 50%;
398
+ bottom: 96px;
399
+ transform: translateX(-50%) translateY(16px);
400
+ opacity: 0;
401
+ pointer-events: none;
402
+ transition: 220ms ease;
403
+ z-index: 50;
404
+ background: rgba(17,21,29,.96);
405
+ border: 1px solid var(--border2);
406
+ border-radius: 999px;
407
+ padding: 10px 16px;
408
+ color: var(--text);
409
+ font-family: var(--mono);
410
+ font-size: 12px;
411
+ box-shadow: var(--shadow);
412
+ white-space: nowrap;
413
+ }
414
+
415
+ #toast.show {
416
+ opacity: 1;
417
+ transform: translateX(-50%) translateY(0);
418
+ }
419
+
420
+ #toast.good { border-color: rgba(45,212,191,.45); color: var(--good); }
421
+ #toast.bad { border-color: rgba(248,113,113,.45); color: var(--bad); }
422
+
423
+ @media (max-width: 720px) {
424
+ #topbar { padding: 0 14px; }
425
+ .brand-sub { display: none; }
426
+ .pill { display: none; }
427
+ .bubble { max-width: calc(100vw - 96px); }
428
+ .turn { gap: 8px; }
429
+ .welcome { margin-top: 4vh; }
430
+ }
431
+ </style>
432
+ </head>
433
+ <body>
434
+ <div id="app">
435
+ <div id="topbar">
436
+ <div class="brand">
437
+ <div class="logo">🧠</div>
438
+ <div>
439
+ <div class="brand-title">Human Intelligence</div>
440
+ <div class="brand-sub">anonymous · answer-engine chat</div>
441
+ </div>
442
+ </div>
443
+ <div class="top-actions">
444
+ <div class="pill">Anonymous mode</div>
445
+ <button class="button secondary" id="newQuestionBtn">New question</button>
446
+ </div>
447
+ </div>
448
+
449
+ <div id="chat">
450
+ <div class="wrap">
451
+ <div class="welcome" id="welcome">
452
+ <h1>Ask a question. Answers appear right here.</h1>
453
+ <p>
454
+ The app searches existing conversation objects behind the scenes,
455
+ but the view stays in one continuous chat. If an answer exists,
456
+ it shows up here. If not, you can write the first one.
457
+ </p>
458
+ </div>
459
+
460
+ <div id="transcript"></div>
461
+ </div>
462
+ </div>
463
+
464
+ <div class="compose">
465
+ <div class="compose-inner">
466
+ <textarea id="prompt" placeholder="Ask a question..."></textarea>
467
+ <div class="compose-row">
468
+ <div class="hint" id="hint">Press Enter to send · Shift+Enter for newline</div>
469
+ <button class="button send" id="sendBtn">Ask</button>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ </div>
474
+
475
+ <div id="toast"></div>
476
+
477
+ <script>
478
+ window.__HI_INIT__ = {{ init_json | safe }};
479
+ </script>
480
+
481
+ <script>
482
+ (() => {
483
+ const S = {
484
+ clientId: null,
485
+ conversation: null,
486
+ currentQuestion: "",
487
+ loading: false,
488
+ };
489
+
490
+ const $ = (id) => document.getElementById(id);
491
+
492
+ function getClientId() {
493
+ let id = localStorage.getItem("hi_client_id");
494
+ if (!id) {
495
+ id = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()))
496
+ .replace(/-/g, "")
497
+ .slice(0, 16);
498
+ localStorage.setItem("hi_client_id", id);
499
+ }
500
+ return id;
501
+ }
502
+
503
+ function toast(msg, kind = "") {
504
+ const t = $("toast");
505
+ t.textContent = msg;
506
+ t.className = "";
507
+ t.classList.add("show");
508
+ if (kind) t.classList.add(kind);
509
+ clearTimeout(t._timer);
510
+ t._timer = setTimeout(() => {
511
+ t.className = "";
512
+ }, 2200);
513
+ }
514
+
515
+ function escapeHtml(s) {
516
+ return String(s)
517
+ .replace(/&/g, "&amp;")
518
+ .replace(/</g, "&lt;")
519
+ .replace(/>/g, "&gt;")
520
+ .replace(/"/g, "&quot;")
521
+ .replace(/\n/g, "<br>");
522
+ }
523
+
524
+ function fmtTime(iso) {
525
+ if (!iso) return "";
526
+ try {
527
+ return new Date(iso).toLocaleString([], {
528
+ month: "short",
529
+ day: "numeric",
530
+ hour: "2-digit",
531
+ minute: "2-digit",
532
+ });
533
+ } catch {
534
+ return iso;
535
+ }
536
+ }
537
+
538
+ function scrollToBottom() {
539
+ const chat = $("chat");
540
+ chat.scrollTop = chat.scrollHeight;
541
+ }
542
+
543
+ function activeVersion(answer) {
544
+ const versions = answer?.versions || [];
545
+ if (!versions.length) return null;
546
+ const id = answer.active_version;
547
+ let found = versions.find(v => v.id === id);
548
+ if (found) return found;
549
+ return [...versions].sort((a, b) => {
550
+ const av = Number(a.votes || 0);
551
+ const bv = Number(b.votes || 0);
552
+ if (bv !== av) return bv - av;
553
+ return String(b.created_at || "").localeCompare(String(a.created_at || ""));
554
+ })[0];
555
+ }
556
+
557
+ function answerScore(answer) {
558
+ const v = activeVersion(answer);
559
+ if (!v) return 0;
560
+ return Number(v.votes || 0);
561
+ }
562
+
563
+ function sortedAnswers(conversation) {
564
+ return [...(conversation?.answers || [])].sort((a, b) => {
565
+ const sa = answerScore(a);
566
+ const sb = answerScore(b);
567
+ if (sb !== sa) return sb - sa;
568
+ return String(b.created_at || "").localeCompare(String(a.created_at || ""));
569
+ });
570
+ }
571
+
572
+ function setComposerLabel() {
573
+ const hasConv = !!S.conversation;
574
+ const answers = S.conversation?.answers || [];
575
+ const prompt = $("prompt");
576
+ const sendBtn = $("sendBtn");
577
+
578
+ if (!hasConv) {
579
+ prompt.placeholder = "Ask a question...";
580
+ sendBtn.textContent = "Ask";
581
+ $("hint").textContent = "Press Enter to ask · Shift+Enter for newline";
582
+ return;
583
+ }
584
+
585
+ if (!answers.length) {
586
+ prompt.placeholder = "Write the first answer...";
587
+ sendBtn.textContent = "Answer";
588
+ $("hint").textContent = "Press Enter to answer · Shift+Enter for newline";
589
+ return;
590
+ }
591
+
592
+ prompt.placeholder = "Add another answer...";
593
+ sendBtn.textContent = "Answer";
594
+ $("hint").textContent = "Press Enter to answer · Shift+Enter for newline";
595
+ }
596
+
597
+ function renderEmptyAnswerPlaceholder(assistantText) {
598
+ return `
599
+ <div class="turn assistant">
600
+ <div class="avatar assistant">✦</div>
601
+ <div>
602
+ <div class="bubble" style="border-style:dashed;">
603
+ ${escapeHtml(assistantText || "No answer yet. You can write one.")}
604
+ </div>
605
+ <div class="meta">
606
+ <span class="chip muted">open answer turn</span>
607
+ <span>Write the first answer below.</span>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ `;
612
+ }
613
+
614
+ function renderAnswerCard(answer, idx) {
615
+ const active = activeVersion(answer);
616
+ if (!active) return "";
617
+
618
+ const others = (answer.versions || []).filter(v => v.id !== active.id);
619
+
620
+ const versionsPanel = others.length ? `
621
+ <button class="mini" type="button" data-toggle-versions="${answer.id}">
622
+ Show versions (${others.length})
623
+ </button>
624
+ <div class="versions" id="versions-${answer.id}">
625
+ ${others.map(v => {
626
+ const up = Number(v.votes || 0);
627
+ const votedUp = v.votes_by_client && v.votes_by_client[S.clientId] === 1;
628
+ const votedDown = v.votes_by_client && v.votes_by_client[S.clientId] === -1;
629
+ return `
630
+ <div class="version">
631
+ <div class="meta">
632
+ <span>version ${escapeHtml(v.id.slice(0, 6))}</span>
633
+ <span>·</span>
634
+ <span>${escapeHtml(v.author || "Anonymous")}</span>
635
+ <span>·</span>
636
+ <span>${fmtTime(v.created_at)}</span>
637
+ <span>·</span>
638
+ <span>votes: ${up}</span>
639
+ ${v.id === active.id ? `<span class="chip good">active</span>` : ""}
640
+ </div>
641
+ <div class="answer-text">${escapeHtml(v.text || "")}</div>
642
+ <div class="actions">
643
+ <button class="mini ${votedUp ? "voted-up" : ""}" type="button"
644
+ data-vote="${answer.id}|${v.id}|1">▲ Upvote</button>
645
+ <button class="mini ${votedDown ? "voted-down" : ""}" type="button"
646
+ data-vote="${answer.id}|${v.id}|-1">▼ Downvote</button>
647
+ </div>
648
+ </div>
649
+ `;
650
+ }).join("")}
651
+ </div>
652
+ ` : "";
653
+
654
+ const activeVotedUp = active.votes_by_client && active.votes_by_client[S.clientId] === 1;
655
+ const activeVotedDown = active.votes_by_client && active.votes_by_client[S.clientId] === -1;
656
+
657
+ return `
658
+ <div class="answer-card" data-answer-id="${answer.id}">
659
+ <div class="answer-top">
660
+ <div class="avatar assistant">🧑</div>
661
+ <div class="answer-main">
662
+ <div class="answer-head">
663
+ <span>answer ${idx + 1}</span>
664
+ <span>·</span>
665
+ <span>${escapeHtml(active.author || "Anonymous")}</span>
666
+ <span>·</span>
667
+ <span>${fmtTime(active.created_at)}</span>
668
+ <span class="chip good">active</span>
669
+ </div>
670
+ <div class="bubble">
671
+ <div class="answer-text">${escapeHtml(active.text || "")}</div>
672
+ </div>
673
+ <div class="actions">
674
+ <button class="mini ${activeVotedUp ? "voted-up" : ""}" type="button"
675
+ data-vote="${answer.id}|${active.id}|1">▲ ${Number(active.votes || 0)}</button>
676
+ <button class="mini ${activeVotedDown ? "voted-down" : ""}" type="button"
677
+ data-vote="${answer.id}|${active.id}|-1"></button>
678
+ <button class="mini" type="button" data-propose="${answer.id}">Propose version</button>
679
+ </div>
680
+ ${versionsPanel}
681
+ <div class="versions" id="propose-${answer.id}" style="display:none; margin-top:12px;">
682
+ <textarea class="mini-proposal" rows="4" placeholder="Propose a better version..."></textarea>
683
+ <div class="actions">
684
+ <button class="mini" type="button" data-submit-proposal="${answer.id}">Submit version</button>
685
+ </div>
686
+ </div>
687
+ </div>
688
+ </div>
689
+ </div>
690
+ `;
691
+ }
692
+
693
+ function renderConversation(questionText) {
694
+ const transcript = $("transcript");
695
+ const welcome = $("welcome");
696
+ transcript.innerHTML = "";
697
+
698
+ if (!S.conversation) {
699
+ welcome.style.display = "block";
700
+ setComposerLabel();
701
+ return;
702
+ }
703
+
704
+ welcome.style.display = "none";
705
+
706
+ const q = questionText || S.conversation.question || "";
707
+ transcript.insertAdjacentHTML("beforeend", `
708
+ <div class="turn user">
709
+ <div>
710
+ <div class="bubble">${escapeHtml(q)}</div>
711
+ <div class="meta">
712
+ <span class="chip muted">question</span>
713
+ <span>${fmtTime(S.conversation.created_at)}</span>
714
+ </div>
715
+ </div>
716
+ <div class="avatar user">U</div>
717
+ </div>
718
+ `);
719
+
720
+ const answers = sortedAnswers(S.conversation);
721
+ if (!answers.length) {
722
+ transcript.insertAdjacentHTML("beforeend", renderEmptyAnswerPlaceholder(
723
+ S.conversation.assistant_text || "No answer yet. You can write one."
724
+ ));
725
+ } else {
726
+ answers.forEach((answer, idx) => {
727
+ transcript.insertAdjacentHTML("beforeend", renderAnswerCard(answer, idx));
728
+ });
729
+ }
730
+
731
+ setComposerLabel();
732
+ bindDynamicHandlers();
733
+ scrollToBottom();
734
+ }
735
+
736
+ function bindDynamicHandlers() {
737
+ document.querySelectorAll("[data-toggle-versions]").forEach(btn => {
738
+ btn.onclick = () => {
739
+ const id = btn.getAttribute("data-toggle-versions");
740
+ const panel = document.getElementById(`versions-${id}`);
741
+ if (!panel) return;
742
+ panel.classList.toggle("open");
743
+ btn.textContent = panel.classList.contains("open")
744
+ ? "Hide versions"
745
+ : `Show versions (${(S.conversation?.answers || []).find(a => a.id === id)?.versions?.length - 1 || 0})`;
746
+ };
747
+ });
748
+
749
+ document.querySelectorAll("[data-propose]").forEach(btn => {
750
+ btn.onclick = () => {
751
+ const id = btn.getAttribute("data-propose");
752
+ const box = document.getElementById(`propose-${id}`);
753
+ if (!box) return;
754
+ box.style.display = box.style.display === "none" ? "block" : "none";
755
+ };
756
+ });
757
+
758
+ document.querySelectorAll("[data-submit-proposal]").forEach(btn => {
759
+ btn.onclick = async () => {
760
+ const answerId = btn.getAttribute("data-submit-proposal");
761
+ const box = document.getElementById(`propose-${answerId}`);
762
+ const textarea = box ? box.querySelector("textarea") : null;
763
+ const text = textarea ? textarea.value.trim() : "";
764
+ if (!text) {
765
+ toast("Empty proposal", "bad");
766
+ return;
767
+ }
768
+ if (!S.conversation) return;
769
+
770
+ btn.disabled = true;
771
+ const old = btn.textContent;
772
+ btn.textContent = "Submitting...";
773
+
774
+ const res = await callBackend("propose", {
775
+ conversation_id: S.conversation.id,
776
+ answer_id: answerId,
777
+ text,
778
+ });
779
+
780
+ btn.disabled = false;
781
+ btn.textContent = old;
782
+
783
+ if (res.ok) {
784
+ S.conversation = res.conversation;
785
+ localStorage.setItem("hi_last_conversation_id", S.conversation.id);
786
+ renderConversation(S.currentQuestion);
787
+ toast("Version saved", "good");
788
+ } else {
789
+ toast(res.error || "Error", "bad");
790
+ }
791
+ };
792
+ });
793
+
794
+ document.querySelectorAll("[data-vote]").forEach(btn => {
795
+ btn.onclick = async () => {
796
+ if (!S.conversation) return;
797
+ const [answerId, versionId, delta] = btn.getAttribute("data-vote").split("|");
798
+ const res = await callBackend("vote", {
799
+ conversation_id: S.conversation.id,
800
+ answer_id: answerId,
801
+ version_id: versionId,
802
+ delta: Number(delta),
803
+ });
804
+
805
+ if (res.ok) {
806
+ S.conversation = res.conversation;
807
+ localStorage.setItem("hi_last_conversation_id", S.conversation.id);
808
+ renderConversation(S.currentQuestion);
809
+ } else {
810
+ toast(res.error || "Error", "bad");
811
+ }
812
+ };
813
+ });
814
+ }
815
+
816
+ async function callBackend(action, payload = {}) {
817
+ const body = {
818
+ action,
819
+ client_id: S.clientId,
820
+ ...payload,
821
+ };
822
+
823
+ const resp = await fetch("/api", {
824
+ method: "POST",
825
+ headers: {
826
+ "Content-Type": "application/json",
827
+ "X-Client-Id": S.clientId,
828
+ },
829
+ body: JSON.stringify(body),
830
+ });
831
+
832
+ return await resp.json();
833
+ }
834
+
835
+ async function askQuestion(question) {
836
+ const res = await callBackend("ask", { question });
837
+ if (!res.ok) {
838
+ toast(res.error || "Error", "bad");
839
+ return;
840
+ }
841
+
842
+ S.conversation = res.conversation;
843
+ S.currentQuestion = question;
844
+ localStorage.setItem("hi_last_conversation_id", S.conversation.id);
845
+
846
+ if (res.matched) {
847
+ toast("Existing answer found", "good");
848
+ } else {
849
+ toast("New conversation created", "good");
850
+ }
851
+
852
+ renderConversation(question);
853
+ }
854
+
855
+ async function postAnswer(text) {
856
+ if (!S.conversation) return;
857
+
858
+ const res = await callBackend("answer", {
859
+ conversation_id: S.conversation.id,
860
+ text,
861
+ });
862
+
863
+ if (!res.ok) {
864
+ toast(res.error || "Error", "bad");
865
+ return;
866
+ }
867
+
868
+ S.conversation = res.conversation;
869
+ localStorage.setItem("hi_last_conversation_id", S.conversation.id);
870
+ renderConversation(S.currentQuestion);
871
+ toast("Answer saved", "good");
872
+ }
873
+
874
+ async function submitPrompt() {
875
+ const input = $("prompt");
876
+ const text = input.value.trim();
877
+ if (!text) return;
878
+
879
+ input.value = "";
880
+ autoGrow(input);
881
+
882
+ if (!S.conversation) {
883
+ await askQuestion(text);
884
+ return;
885
+ }
886
+
887
+ await postAnswer(text);
888
+ }
889
+
890
+ function autoGrow(node) {
891
+ node.style.height = "auto";
892
+ node.style.height = Math.min(node.scrollHeight, 240) + "px";
893
+ }
894
+
895
+ async function loadSavedConversation() {
896
+ const savedId = localStorage.getItem("hi_last_conversation_id");
897
+ if (!savedId) return;
898
+
899
+ const res = await callBackend("get_conversation", { conversation_id: savedId });
900
+ if (res.ok && res.conversation) {
901
+ S.conversation = res.conversation;
902
+ S.currentQuestion = res.conversation.question || "";
903
+ renderConversation(S.currentQuestion);
904
+ }
905
+ }
906
+
907
+ function newQuestion() {
908
+ S.conversation = null;
909
+ S.currentQuestion = "";
910
+ localStorage.removeItem("hi_last_conversation_id");
911
+ $("transcript").innerHTML = "";
912
+ $("welcome").style.display = "block";
913
+ $("prompt").value = "";
914
+ setComposerLabel();
915
+ $("prompt").focus();
916
+ }
917
+
918
+ function init() {
919
+ S.clientId = getClientId();
920
+
921
+ $("sendBtn").addEventListener("click", submitPrompt);
922
+ $("newQuestionBtn").addEventListener("click", newQuestion);
923
+
924
+ $("prompt").addEventListener("input", (e) => autoGrow(e.target));
925
+ $("prompt").addEventListener("keydown", (e) => {
926
+ if (e.key === "Enter" && !e.shiftKey) {
927
+ e.preventDefault();
928
+ submitPrompt();
929
+ }
930
+ });
931
+
932
+ const initData = window.__HI_INIT__ || {};
933
+ if (initData.client_id) {
934
+ S.clientId = initData.client_id;
935
+ }
936
+
937
+ loadSavedConversation().then(() => {
938
+ if (!S.conversation) {
939
+ setComposerLabel();
940
+ $("prompt").focus();
941
+ }
942
+ });
943
+ }
944
+
945
+ init();
946
+ })();
947
+ </script>
948
+ </body>
949
+ </html>