@dmsdc-ai/aigentry-deliberation 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1478 @@
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>πŸ”­ Deliberation Observer</title>
7
+ <style>
8
+ /* ── Reset & Base ─────────────────────────────────────── */
9
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
10
+ :root {
11
+ --bg: #0d1117;
12
+ --surface: #161b22;
13
+ --border: #30363d;
14
+ --border-h: #58a6ff;
15
+ --text: #c9d1d9;
16
+ --muted: #8b949e;
17
+ --blue: #58a6ff;
18
+ --green: #3fb950;
19
+ --red: #f85149;
20
+ --yellow: #d29922;
21
+ --purple: #d2a8ff;
22
+ }
23
+ body {
24
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Text', sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ min-height: 100vh;
28
+ font-size: 14px;
29
+ line-height: 1.5;
30
+ }
31
+
32
+ /* ── Animations ───────────────────────────────────────── */
33
+ @keyframes pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.15); opacity: 0.85; } }
34
+ @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
35
+ @keyframes spin { to { transform: rotate(360deg); } }
36
+ @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
37
+
38
+ /* ── Layout ───────────────────────────────────────────── */
39
+ #app { display: flex; flex-direction: column; height: 100vh; }
40
+ header {
41
+ display: flex; align-items: center; justify-content: space-between;
42
+ padding: 14px 20px;
43
+ border-bottom: 1px solid var(--border);
44
+ background: var(--surface);
45
+ position: sticky; top: 0; z-index: 100;
46
+ }
47
+ header h1 { font-size: 1.1em; font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
48
+ .header-actions { display: flex; gap: 8px; align-items: center; }
49
+ .btn {
50
+ background: #21262d; border: 1px solid var(--border); color: var(--text);
51
+ padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 0.82em;
52
+ transition: border-color 0.15s, background 0.15s;
53
+ }
54
+ .btn:hover { border-color: var(--blue); background: #1c2128; }
55
+ .btn.back { display: flex; align-items: center; gap: 6px; }
56
+
57
+ /* ── Screen management ────────────────────────────────── */
58
+ .screen { display: none; flex: 1; overflow: hidden; }
59
+ .screen.active { display: flex; flex-direction: column; }
60
+
61
+ /* ═══════════════════════════════════════════════════════
62
+ SCREEN 1: Session List
63
+ ══════════════════════════════════════════════════════ */
64
+ #screen-list { padding: 20px; overflow-y: auto; }
65
+ .grid {
66
+ display: grid;
67
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
68
+ gap: 14px;
69
+ max-width: 1200px;
70
+ margin: 0 auto;
71
+ }
72
+ .session-card {
73
+ background: var(--surface); border: 1px solid var(--border);
74
+ border-radius: 10px; padding: 18px; cursor: pointer;
75
+ transition: border-color 0.2s, transform 0.15s;
76
+ animation: fadeSlideIn 0.3s ease both;
77
+ }
78
+ .session-card:hover { border-color: var(--blue); transform: translateY(-1px); }
79
+ .session-card.active-border { border-color: var(--green); }
80
+
81
+ .card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12px; gap: 8px; }
82
+ .card-topic { font-size: 0.95em; font-weight: 600; color: var(--text); flex: 1; }
83
+
84
+ /* Status badges */
85
+ .badge {
86
+ display: inline-flex; align-items: center; gap: 4px;
87
+ padding: 2px 8px; border-radius: 12px;
88
+ font-size: 0.72em; font-weight: 600; white-space: nowrap;
89
+ }
90
+ .badge.active { background: #23833022; color: var(--green); border: 1px solid #3fb95044; }
91
+ .badge.awaiting_synthesis { background: #1f6feb22; color: var(--blue); border: 1px solid #58a6ff44; }
92
+ .badge.synthesized { background: #30363d; color: var(--muted); border: 1px solid var(--border); }
93
+
94
+ /* Mini avatar row */
95
+ .avatar-row { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
96
+ .avatar { position: relative; display: inline-flex; flex-shrink: 0; }
97
+ .avatar svg { display: block; }
98
+ .avatar.idle svg { opacity: 0.45; }
99
+ .avatar.speaking svg { animation: pulse 1.4s ease-in-out infinite; }
100
+
101
+ /* Speech bubble */
102
+ .bubble {
103
+ position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
104
+ background: #21262d; border: 1px solid var(--border);
105
+ border-radius: 6px; padding: 5px 8px;
106
+ font-size: 0.72em; color: var(--text); white-space: nowrap;
107
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis;
108
+ pointer-events: none; z-index: 10;
109
+ opacity: 0; transition: opacity 0.15s;
110
+ }
111
+ .bubble::after {
112
+ content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
113
+ border: 5px solid transparent; border-top-color: var(--border);
114
+ }
115
+ .avatar.speaking .bubble,
116
+ .avatar:hover .bubble { opacity: 1; }
117
+
118
+ /* Progress bar */
119
+ .progress-wrap { margin-bottom: 10px; }
120
+ .progress-label { display: flex; justify-content: space-between; font-size: 0.78em; color: var(--muted); margin-bottom: 4px; }
121
+ .progress-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
122
+ .progress-fill { height: 100%; background: var(--blue); border-radius: 2px; transition: width 0.4s ease; }
123
+ .progress-fill.green { background: var(--green); }
124
+
125
+ /* Card meta */
126
+ .card-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.78em; color: var(--muted); }
127
+
128
+ /* Empty / loading */
129
+ .empty { color: var(--muted); text-align: center; padding: 60px 20px; font-size: 0.95em; }
130
+ .spinner {
131
+ width: 20px; height: 20px; border: 2px solid var(--border);
132
+ border-top-color: var(--blue); border-radius: 50%;
133
+ animation: spin 0.8s linear infinite; margin: 0 auto 12px;
134
+ }
135
+
136
+ /* ═══════════════════════════════════════════════════════
137
+ SCREEN 2: Session Detail
138
+ ══════════════════════════════════════════════════════ */
139
+ #screen-detail { overflow: hidden; }
140
+
141
+ /* Speaker top bar */
142
+ .speaker-bar {
143
+ background: var(--surface); border-bottom: 1px solid var(--border);
144
+ padding: 12px 20px; display: flex; gap: 16px; overflow-x: auto;
145
+ scrollbar-width: none;
146
+ }
147
+ .speaker-bar::-webkit-scrollbar { display: none; }
148
+ .speaker-slot { display: flex; flex-direction: column; align-items: center; gap: 5px; flex-shrink: 0; }
149
+ .speaker-name { font-size: 0.7em; color: var(--muted); max-width: 60px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
150
+ .speaker-slot.current-speaker .speaker-name { color: var(--green); font-weight: 600; }
151
+
152
+ /* Detail body: log + sidebar */
153
+ .detail-body { display: flex; flex: 1; overflow: hidden; }
154
+
155
+ /* Chat log */
156
+ #chat-log {
157
+ flex: 1; overflow-y: auto; padding: 16px 20px;
158
+ display: flex; flex-direction: column; gap: 10px;
159
+ }
160
+ .log-entry {
161
+ display: flex; gap: 12px; align-items: flex-start;
162
+ animation: fadeSlideIn 0.35s ease both;
163
+ border-left: 3px solid transparent; padding-left: 10px; border-radius: 0 6px 6px 0;
164
+ background: var(--surface); padding: 12px; border-radius: 6px;
165
+ }
166
+ .entry-avatar { flex-shrink: 0; }
167
+ .entry-body { flex: 1; min-width: 0; }
168
+ .entry-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
169
+ .entry-speaker { font-weight: 600; font-size: 0.88em; }
170
+ .entry-round { font-size: 0.72em; color: var(--muted); background: var(--border); padding: 1px 6px; border-radius: 10px; }
171
+ .entry-drift { font-size: 0.72em; color: var(--yellow); }
172
+ .entry-content { font-size: 0.88em; line-height: 1.6; color: var(--text); white-space: pre-wrap; word-break: break-word; }
173
+
174
+ /* Sidebar */
175
+ #sidebar {
176
+ width: 240px; flex-shrink: 0;
177
+ border-left: 1px solid var(--border);
178
+ overflow-y: auto; padding: 16px;
179
+ background: var(--surface);
180
+ display: flex; flex-direction: column; gap: 18px;
181
+ }
182
+ .sidebar-section h3 { font-size: 0.78em; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
183
+
184
+ /* Vote bars */
185
+ .vote-bar-wrap { display: flex; flex-direction: column; gap: 6px; }
186
+ .vote-row { display: flex; align-items: center; gap: 8px; }
187
+ .vote-label { font-size: 0.75em; color: var(--muted); width: 80px; }
188
+ .vote-track { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
189
+ .vote-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; }
190
+ .vote-fill.agree { background: var(--green); }
191
+ .vote-fill.disagree { background: var(--red); }
192
+ .vote-fill.conditional { background: var(--yellow); }
193
+ .vote-count { font-size: 0.75em; color: var(--muted); width: 20px; text-align: right; }
194
+
195
+ /* Degradation indicators */
196
+ .deg-row { display: flex; align-items: center; gap: 8px; font-size: 0.8em; }
197
+ .deg-dot { font-size: 1em; }
198
+ .deg-label { color: var(--muted); flex: 1; }
199
+
200
+ /* Role list */
201
+ .role-item { display: flex; align-items: center; gap: 6px; font-size: 0.8em; margin-bottom: 4px; }
202
+ .role-item .role-speaker { color: var(--text); }
203
+ .role-item .role-name { color: var(--muted); }
204
+
205
+ /* Connection warning */
206
+ .conn-warn {
207
+ background: #f8514918; border: 1px solid #f8514944;
208
+ color: var(--red); font-size: 0.8em; padding: 8px 12px;
209
+ border-radius: 6px; display: flex; align-items: center; gap: 8px;
210
+ }
211
+ .conn-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); animation: blink 1s infinite; }
212
+
213
+ /* ═══════════════════════════════════════════════════════
214
+ SCREEN 3: LLM Configuration
215
+ ══════════════════════════════════════════════════════ */
216
+ #screen-config { padding: 24px 20px; overflow-y: auto; }
217
+
218
+ .config-header {
219
+ max-width: 900px; margin: 0 auto 24px;
220
+ }
221
+ .config-header h2 {
222
+ font-size: 1.25em; font-weight: 700; color: var(--text); margin-bottom: 6px;
223
+ }
224
+ .config-header p {
225
+ font-size: 0.88em; color: var(--muted); margin-bottom: 12px;
226
+ }
227
+ .config-mode-badge {
228
+ display: inline-flex; align-items: center; gap: 6px;
229
+ background: #1f6feb22; border: 1px solid #58a6ff44;
230
+ color: var(--blue); font-size: 0.75em; font-weight: 600;
231
+ padding: 3px 10px; border-radius: 12px;
232
+ }
233
+ .config-mode-badge.auto { background: #23833022; border-color: #3fb95044; color: var(--green); }
234
+
235
+ /* LLM Grid */
236
+ .llm-grid {
237
+ display: grid;
238
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
239
+ gap: 12px;
240
+ max-width: 900px;
241
+ margin: 0 auto 28px;
242
+ }
243
+
244
+ .llm-card {
245
+ background: var(--surface); border: 1px solid var(--border);
246
+ border-radius: 10px; padding: 16px;
247
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
248
+ transition: border-color 0.2s, transform 0.15s, opacity 0.2s;
249
+ animation: fadeSlideIn 0.3s ease both;
250
+ cursor: pointer; user-select: none;
251
+ }
252
+ .llm-card:hover { border-color: var(--border-h); transform: translateY(-1px); }
253
+ .llm-card.enabled { border-color: var(--green); background: #23833010; }
254
+ .llm-card.disabled { opacity: 0.55; }
255
+
256
+ .llm-card-name {
257
+ font-size: 0.9em; font-weight: 600; color: var(--text); text-align: center;
258
+ }
259
+ .llm-card-cmd {
260
+ font-size: 0.75em; color: var(--muted); font-family: 'SF Mono', 'Fira Code', monospace;
261
+ }
262
+
263
+ /* Toggle switch */
264
+ .toggle-wrap { display: flex; align-items: center; gap: 8px; }
265
+ .toggle {
266
+ position: relative; display: inline-block; width: 36px; height: 20px;
267
+ }
268
+ .toggle input { opacity: 0; width: 0; height: 0; }
269
+ .toggle-track {
270
+ position: absolute; inset: 0;
271
+ background: var(--border); border-radius: 20px;
272
+ transition: background 0.2s;
273
+ cursor: pointer;
274
+ }
275
+ .toggle input:checked + .toggle-track { background: var(--green); }
276
+ .toggle-thumb {
277
+ position: absolute; top: 3px; left: 3px;
278
+ width: 14px; height: 14px;
279
+ background: white; border-radius: 50%;
280
+ transition: transform 0.2s;
281
+ pointer-events: none;
282
+ }
283
+ .toggle input:checked ~ .toggle-thumb { transform: translateX(16px); }
284
+
285
+ /* Config actions */
286
+ .config-actions {
287
+ display: flex; gap: 10px; align-items: center;
288
+ max-width: 900px; margin: 0 auto;
289
+ flex-wrap: wrap;
290
+ }
291
+ .btn-primary {
292
+ background: var(--blue); border: 1px solid #79b8ff44;
293
+ color: #0d1117; font-weight: 700;
294
+ padding: 7px 18px; border-radius: 6px; cursor: pointer; font-size: 0.85em;
295
+ transition: background 0.15s, transform 0.1s;
296
+ }
297
+ .btn-primary:hover { background: #79b8ff; transform: translateY(-1px); }
298
+ .btn-secondary {
299
+ background: #21262d; border: 1px solid var(--border);
300
+ color: var(--text); font-weight: 500;
301
+ padding: 7px 18px; border-radius: 6px; cursor: pointer; font-size: 0.85em;
302
+ transition: border-color 0.15s, background 0.15s;
303
+ }
304
+ .btn-secondary:hover { border-color: var(--muted); background: #1c2128; }
305
+
306
+ /* Gear button */
307
+ .btn-gear {
308
+ background: #21262d; border: 1px solid var(--border); color: var(--muted);
309
+ width: 30px; height: 30px; border-radius: 6px; cursor: pointer; font-size: 1em;
310
+ display: flex; align-items: center; justify-content: center;
311
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
312
+ }
313
+ .btn-gear:hover { border-color: var(--blue); color: var(--blue); background: #1c2128; }
314
+ .btn-gear.active { border-color: var(--blue); color: var(--blue); background: #1f6feb22; }
315
+
316
+ /* Toast */
317
+ .toast {
318
+ position: fixed; top: 64px; left: 50%; transform: translateX(-50%);
319
+ z-index: 200; padding: 10px 20px; border-radius: 8px; font-size: 0.85em; font-weight: 600;
320
+ white-space: nowrap; pointer-events: none;
321
+ animation: toastIn 0.25s ease;
322
+ box-shadow: 0 4px 16px #00000066;
323
+ }
324
+ .toast.success { background: #238330cc; border: 1px solid var(--green); color: #fff; }
325
+ .toast.error { background: #b91c1c99; border: 1px solid var(--red); color: #fff; }
326
+ @keyframes toastIn { from { opacity: 0; top: 50px; } to { opacity: 1; top: 64px; } }
327
+ @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
328
+ .toast.hiding { animation: toastOut 0.35s ease forwards; }
329
+
330
+ /* ── Responsive ───────────────────────────────────────── */
331
+ @media (max-width: 640px) {
332
+ #sidebar { display: none; }
333
+ .grid { grid-template-columns: 1fr; }
334
+ .bubble { max-width: 140px; }
335
+ .llm-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
336
+ }
337
+
338
+ /* ═══════════════════════════════════════════════════════
339
+ FEATURE 1: CLI Install Status Dots
340
+ ══════════════════════════════════════════════════════ */
341
+ .cli-status-dot {
342
+ width: 8px; height: 8px; border-radius: 50%;
343
+ display: inline-block; margin-left: 6px;
344
+ flex-shrink: 0;
345
+ }
346
+ .cli-status-dot.installed { background: var(--green); box-shadow: 0 0 4px var(--green); }
347
+ .cli-status-dot.not-installed { background: var(--red); }
348
+ .cli-status-dot.loading { background: var(--muted); }
349
+
350
+ /* ═══════════════════════════════════════════════════════
351
+ FEATURE 2: Browser LLM Tabs
352
+ ══════════════════════════════════════════════════════ */
353
+ .browser-tabs-section {
354
+ margin: 20px auto 0;
355
+ padding: 16px;
356
+ background: var(--surface); border: 1px solid var(--border);
357
+ border-radius: 10px; max-width: 900px;
358
+ }
359
+ .section-header {
360
+ display: flex; align-items: center; justify-content: space-between;
361
+ cursor: pointer; user-select: none;
362
+ }
363
+ .section-header h3 { font-size: 0.9em; color: var(--text); margin: 0; font-weight: 600; }
364
+ .section-toggle { color: var(--muted); font-size: 0.8em; transition: transform 0.2s; display: inline-block; }
365
+ .section-toggle.open { transform: rotate(90deg); }
366
+ .browser-tabs-body { margin-top: 14px; }
367
+ .browser-tab-item {
368
+ display: flex; align-items: center; gap: 10px;
369
+ padding: 8px 12px; border-radius: 6px; background: var(--bg);
370
+ margin-bottom: 6px; font-size: 0.85em;
371
+ }
372
+ .browser-tab-item .tab-title { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
373
+ .browser-tab-item .tab-url { color: var(--muted); font-size: 0.8em; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
374
+ .cdp-status { font-size: 0.8em; color: var(--muted); margin-bottom: 10px; }
375
+ .cdp-status.available { color: var(--green); }
376
+ .cdp-status.unavailable { color: var(--red); }
377
+
378
+ /* ═══════════════════════════════════════════════════════
379
+ FEATURE 3: Quick Start FAB + Modal
380
+ ══════════════════════════════════════════════════════ */
381
+ .fab {
382
+ position: fixed; bottom: 24px; right: 24px; z-index: 90;
383
+ width: 56px; height: 56px; border-radius: 50%;
384
+ background: var(--blue); color: white; border: none;
385
+ font-size: 1.4em; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
386
+ transition: transform 0.15s, box-shadow 0.15s;
387
+ display: flex; align-items: center; justify-content: center;
388
+ }
389
+ .fab:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(0,0,0,0.5); }
390
+
391
+ .modal-overlay {
392
+ position: fixed; inset: 0; z-index: 150;
393
+ background: rgba(0,0,0,0.6); display: flex;
394
+ align-items: center; justify-content: center;
395
+ }
396
+ .modal {
397
+ background: var(--surface); border: 1px solid var(--border);
398
+ border-radius: 12px; padding: 24px; width: 90%; max-width: 480px;
399
+ animation: fadeSlideIn 0.25s ease;
400
+ }
401
+ .modal h2 { font-size: 1.1em; margin-bottom: 18px; color: var(--text); }
402
+ .form-group { margin-bottom: 16px; }
403
+ .form-group label {
404
+ display: block; font-size: 0.8em; color: var(--muted);
405
+ margin-bottom: 6px; font-weight: 600;
406
+ text-transform: uppercase; letter-spacing: 0.05em;
407
+ }
408
+ .form-input {
409
+ width: 100%; padding: 10px 12px; background: var(--bg);
410
+ border: 1px solid var(--border); border-radius: 6px;
411
+ color: var(--text); font-size: 0.9em; outline: none;
412
+ transition: border-color 0.15s; font-family: inherit;
413
+ }
414
+ .form-input:focus { border-color: var(--blue); }
415
+ .form-input-sm { width: 80px; }
416
+
417
+ .speaker-chips { display: flex; flex-wrap: wrap; gap: 6px; }
418
+ .speaker-chip {
419
+ display: flex; align-items: center; gap: 6px;
420
+ padding: 6px 12px; border-radius: 20px;
421
+ background: var(--bg); border: 1px solid var(--border);
422
+ cursor: pointer; font-size: 0.82em; color: var(--muted);
423
+ transition: all 0.15s; user-select: none;
424
+ }
425
+ .speaker-chip.selected {
426
+ border-color: var(--blue); background: #58a6ff18; color: var(--text);
427
+ }
428
+
429
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
430
+
431
+ /* ═══════════════════════════════════════════════════════
432
+ FEATURE 4: LLM Statistics Screen
433
+ ══════════════════════════════════════════════════════ */
434
+ #screen-stats { padding: 0; overflow: hidden; }
435
+ #screen-stats > div { flex: 1; }
436
+
437
+ .stats-summary {
438
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
439
+ gap: 12px; margin-bottom: 24px;
440
+ }
441
+ .stat-card {
442
+ background: var(--surface); border: 1px solid var(--border);
443
+ border-radius: 10px; padding: 16px; text-align: center;
444
+ animation: fadeSlideIn 0.3s ease both;
445
+ }
446
+ .stat-value { font-size: 1.8em; font-weight: 700; color: var(--blue); }
447
+ .stat-label { font-size: 0.75em; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }
448
+
449
+ .stats-speakers { display: flex; flex-direction: column; gap: 10px; }
450
+ .speaker-stat-row {
451
+ display: flex; align-items: center; gap: 14px;
452
+ background: var(--surface); border: 1px solid var(--border);
453
+ border-radius: 8px; padding: 14px 16px;
454
+ animation: fadeSlideIn 0.3s ease both;
455
+ }
456
+ .speaker-stat-info { flex: 1; }
457
+ .speaker-stat-name { font-weight: 600; font-size: 0.9em; color: var(--text); }
458
+ .speaker-stat-detail { font-size: 0.78em; color: var(--muted); margin-top: 2px; }
459
+ .speaker-stat-bar { width: 120px; }
460
+ .mini-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-top: 4px; }
461
+ .mini-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
462
+ </style>
463
+ </head>
464
+ <body>
465
+ <div id="app">
466
+ <!-- ── Header ─────────────────────────────────────── -->
467
+ <header>
468
+ <h1 id="header-title">πŸ”­ Deliberation Observer</h1>
469
+ <div class="header-actions">
470
+ <button class="btn back" id="btn-back" style="display:none">
471
+ ← Back
472
+ </button>
473
+ <div id="conn-status"></div>
474
+ <button class="btn-gear" id="btn-stats" title="LLM Statistics" onclick="showStats()">πŸ“Š</button>
475
+ <button class="btn-gear" id="btn-gear" title="LLM Configuration" onclick="showConfig()">βš™</button>
476
+ <button class="btn" id="btn-refresh" onclick="loadSessions()">↻ Refresh</button>
477
+ </div>
478
+ </header>
479
+
480
+ <!-- ── Screen 1: Session List ─────────────────────── -->
481
+ <div id="screen-list" class="screen active">
482
+ <div class="grid" id="session-grid">
483
+ <div class="empty"><div class="spinner"></div>Loading sessions…</div>
484
+ </div>
485
+ </div>
486
+
487
+ <!-- ── Screen 2: Session Detail ───────────────────── -->
488
+ <div id="screen-detail" class="screen">
489
+ <!-- Speaker top bar -->
490
+ <div class="speaker-bar" id="speaker-bar"></div>
491
+
492
+ <!-- Body -->
493
+ <div class="detail-body">
494
+ <div id="chat-log"></div>
495
+
496
+ <!-- Sidebar -->
497
+ <div id="sidebar">
498
+ <!-- Round progress -->
499
+ <div class="sidebar-section" id="sb-round">
500
+ <h3>Round Progress</h3>
501
+ <div class="progress-label"><span id="sb-round-label">β€”</span></div>
502
+ <div class="progress-track"><div class="progress-fill" id="sb-round-fill" style="width:0%"></div></div>
503
+ </div>
504
+
505
+ <!-- Vote tally -->
506
+ <div class="sidebar-section" id="sb-votes">
507
+ <h3>Vote Tally</h3>
508
+ <div class="vote-bar-wrap">
509
+ <div class="vote-row">
510
+ <span class="vote-label">βœ… AGREE</span>
511
+ <div class="vote-track"><div class="vote-fill agree" id="vf-agree" style="width:0%"></div></div>
512
+ <span class="vote-count" id="vc-agree">0</span>
513
+ </div>
514
+ <div class="vote-row">
515
+ <span class="vote-label">❌ DISAGREE</span>
516
+ <div class="vote-track"><div class="vote-fill disagree" id="vf-disagree" style="width:0%"></div></div>
517
+ <span class="vote-count" id="vc-disagree">0</span>
518
+ </div>
519
+ <div class="vote-row">
520
+ <span class="vote-label">🟑 CONDITIONAL</span>
521
+ <div class="vote-track"><div class="vote-fill conditional" id="vf-conditional" style="width:0%"></div></div>
522
+ <span class="vote-count" id="vc-conditional">0</span>
523
+ </div>
524
+ </div>
525
+ </div>
526
+
527
+ <!-- Degradation -->
528
+ <div class="sidebar-section" id="sb-deg">
529
+ <h3>Degradation</h3>
530
+ <div id="deg-list"></div>
531
+ </div>
532
+
533
+ <!-- Ordering / Roles -->
534
+ <div class="sidebar-section" id="sb-roles">
535
+ <h3>Roles</h3>
536
+ <div id="roles-list"></div>
537
+ </div>
538
+ </div>
539
+ </div>
540
+ </div>
541
+
542
+ <!-- ── Screen 3: LLM Configuration ────────────────── -->
543
+ <div id="screen-config" class="screen">
544
+ <div class="config-header">
545
+ <h2>LLM Configuration</h2>
546
+ <p>토둠에 μ°Έμ—¬ν•  LLM을 μ„ νƒν•˜μ„Έμš” / Select LLMs for deliberation</p>
547
+ <span class="config-mode-badge auto" id="config-mode-badge">Auto-detect</span>
548
+ </div>
549
+
550
+ <div class="llm-grid" id="llm-grid">
551
+ <!-- Cards injected by renderLlmGrid() -->
552
+ </div>
553
+
554
+ <div class="config-actions">
555
+ <button class="btn-primary" onclick="saveConfig()">Save</button>
556
+ <button class="btn-secondary" onclick="resetConfig()">Reset to Auto-detect</button>
557
+ </div>
558
+
559
+ <!-- Feature 2: Browser LLM Tabs -->
560
+ <div class="browser-tabs-section">
561
+ <div class="section-header" onclick="toggleBrowserTabs()">
562
+ <h3>🌐 Browser LLM Tabs</h3>
563
+ <span class="section-toggle" id="browser-toggle">β–Ά</span>
564
+ </div>
565
+ <div class="browser-tabs-body" id="browser-tabs-body" style="display:none">
566
+ <div id="browser-tabs-list"></div>
567
+ <button class="btn" onclick="loadBrowserTabs()" style="margin-top:8px">↻ Refresh</button>
568
+ </div>
569
+ </div>
570
+ </div>
571
+
572
+ <!-- ── Screen 4: LLM Statistics ───────────────────── -->
573
+ <div id="screen-stats" class="screen">
574
+ <div style="padding:20px;overflow-y:auto;flex:1;max-width:900px;margin:0 auto;width:100%">
575
+ <div class="config-header">
576
+ <h2>πŸ“Š LLM Statistics</h2>
577
+ <p>ν† λ‘  μ°Έμ—¬ 톡계 / Deliberation participation stats</p>
578
+ </div>
579
+ <div class="stats-summary" id="stats-summary"></div>
580
+ <div class="stats-speakers" id="stats-speakers"></div>
581
+ </div>
582
+ </div>
583
+
584
+ <!-- Feature 3: FAB -->
585
+ <button class="fab" id="fab-start" onclick="showQuickStart()" title="New Deliberation">+</button>
586
+
587
+ <!-- Feature 3: Quick Start Modal -->
588
+ <div class="modal-overlay" id="quick-start-modal" style="display:none" onclick="if(event.target===this)hideQuickStart()">
589
+ <div class="modal">
590
+ <h2>πŸš€ New Deliberation</h2>
591
+ <div class="form-group">
592
+ <label>Topic</label>
593
+ <input type="text" id="qs-topic" placeholder="ν† λ‘  주제λ₯Ό μž…λ ₯ν•˜μ„Έμš”" class="form-input">
594
+ </div>
595
+ <div class="form-group">
596
+ <label>Speakers</label>
597
+ <div class="speaker-chips" id="qs-speakers"></div>
598
+ </div>
599
+ <div class="form-group">
600
+ <label>Max Rounds</label>
601
+ <input type="number" id="qs-rounds" value="3" min="1" max="20" class="form-input form-input-sm">
602
+ </div>
603
+ <div class="modal-actions">
604
+ <button class="btn" onclick="hideQuickStart()">Cancel</button>
605
+ <button class="btn-primary" onclick="startDeliberation()">Start</button>
606
+ </div>
607
+ </div>
608
+ </div>
609
+ </div>
610
+
611
+ <script>
612
+ /* ══════════════════════════════════════════════════════
613
+ DATA
614
+ ══════════════════════════════════════════════════════ */
615
+ let sessions = [];
616
+ let currentSession = null;
617
+ let currentStream = null;
618
+ let previousScreen = 'list'; // tracks where to go Back from config
619
+
620
+ /* ══════════════════════════════════════════════════════
621
+ MASCOT SYSTEM
622
+ ══════════════════════════════════════════════════════ */
623
+ const MASCOTS = {
624
+ claude: { initial: "C", color: "#D4744E", shape: "circle" },
625
+ chatgpt: { initial: "G", color: "#10A37F", shape: "circle" },
626
+ gemini: { initial: "Gm", color: "#4285F4", shape: "diamond" },
627
+ codex: { initial: "Cx", color: "#171717", shape: "square" },
628
+ copilot: { initial: "Co", color: "#0078D4", shape: "hexagon" },
629
+ perplexity: { initial: "P", color: "#20B8CD", shape: "circle" },
630
+ deepseek: { initial: "D", color: "#4D6BFF", shape: "circle" },
631
+ qwen: { initial: "Q", color: "#6F42C1", shape: "circle" },
632
+ aigentry: { initial: "A", color: "#888888", shape: "star" },
633
+ };
634
+
635
+ /** Derive a stable HSL color from a speaker name string */
636
+ function hashColor(name) {
637
+ let h = 0;
638
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xFFFFFF;
639
+ return `hsl(${h % 360}, 55%, 55%)`;
640
+ }
641
+
642
+ /** Return mascot config (or generated fallback) for a speaker */
643
+ function getMascot(speaker) {
644
+ const key = (speaker || '').toLowerCase().split(/[-_\s]/)[0];
645
+ if (MASCOTS[key]) return MASCOTS[key];
646
+ return {
647
+ initial: speaker.charAt(0).toUpperCase(),
648
+ color: hashColor(speaker),
649
+ shape: 'circle',
650
+ };
651
+ }
652
+
653
+ /** Render an SVG avatar. size in px, state: 'idle'|'speaking'|'done' */
654
+ function renderAvatar(speaker, state = 'done', size = 36) {
655
+ const m = getMascot(speaker);
656
+ const s = size;
657
+ const cx = s / 2;
658
+ const fs = s * 0.36;
659
+ const col = m.color;
660
+ const cls = `avatar ${state}`;
661
+
662
+ let shape = '';
663
+ if (m.shape === 'circle') {
664
+ shape = `<circle cx="${cx}" cy="${cx}" r="${cx - 1}" fill="${col}" />`;
665
+ } else if (m.shape === 'diamond') {
666
+ const d = cx - 1;
667
+ shape = `<polygon points="${cx},1 ${s-1},${cx} ${cx},${s-1} 1,${cx}" fill="${col}" />`;
668
+ } else if (m.shape === 'square') {
669
+ shape = `<rect x="1" y="1" width="${s-2}" height="${s-2}" rx="4" fill="${col}" />`;
670
+ } else if (m.shape === 'hexagon') {
671
+ const r = cx - 1, h = r * Math.sin(Math.PI / 3);
672
+ const pts = [
673
+ [cx, cx - r], [cx + h, cx - r/2], [cx + h, cx + r/2],
674
+ [cx, cx + r], [cx - h, cx + r/2], [cx - h, cx - r/2]
675
+ ].map(p => p.join(',')).join(' ');
676
+ shape = `<polygon points="${pts}" fill="${col}" />`;
677
+ } else if (m.shape === 'star') {
678
+ const r1 = cx - 1, r2 = r1 * 0.45;
679
+ const pts = [];
680
+ for (let i = 0; i < 10; i++) {
681
+ const a = (Math.PI / 5) * i - Math.PI / 2;
682
+ const r = i % 2 === 0 ? r1 : r2;
683
+ pts.push([cx + r * Math.cos(a), cx + r * Math.sin(a)].join(','));
684
+ }
685
+ shape = `<polygon points="${pts.join(' ')}" fill="${col}" />`;
686
+ }
687
+
688
+ return `<span class="${cls}" title="${escHtml(speaker)}">
689
+ <svg width="${s}" height="${s}" viewBox="0 0 ${s} ${s}" xmlns="http://www.w3.org/2000/svg">
690
+ ${shape}
691
+ <text x="${cx}" y="${cx}" text-anchor="middle" dominant-baseline="central"
692
+ fill="white" font-size="${fs}" font-weight="700"
693
+ font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">${escHtml(m.initial)}</text>
694
+ </svg>
695
+ </span>`;
696
+ }
697
+
698
+ /** Render a speech bubble tooltip for an avatar slot */
699
+ function renderSpeechBubble(text) {
700
+ if (!text) return '';
701
+ const preview = text.length > 50 ? text.substring(0, 50) + '…' : text;
702
+ return `<span class="bubble">${escHtml(preview)}</span>`;
703
+ }
704
+
705
+ /* ══════════════════════════════════════════════════════
706
+ API
707
+ ══════════════════════════════════════════════════════ */
708
+ async function loadSessions() {
709
+ try {
710
+ const res = await fetch('/api/sessions');
711
+ sessions = await res.json();
712
+ renderSessionList(sessions);
713
+ } catch (e) {
714
+ document.getElementById('session-grid').innerHTML =
715
+ `<div class="empty">Connection failed: ${escHtml(e.message)}</div>`;
716
+ }
717
+ }
718
+
719
+ function connectSSE(sessionId) {
720
+ if (currentStream) { currentStream.close(); currentStream = null; }
721
+
722
+ const es = new EventSource(`/api/sessions/${sessionId}/stream`);
723
+ currentStream = es;
724
+
725
+ es.addEventListener('connected', () => setConnStatus('connected'));
726
+
727
+ es.addEventListener('snapshot', (e) => {
728
+ const data = JSON.parse(e.data);
729
+ currentSession = data;
730
+ renderSessionDetail(data);
731
+ setConnStatus('connected');
732
+ });
733
+
734
+ es.addEventListener('turn', (e) => {
735
+ const entry = JSON.parse(e.data);
736
+ if (currentSession) {
737
+ currentSession.log = currentSession.log || [];
738
+ currentSession.log.push(entry);
739
+ }
740
+ addLogEntry(entry);
741
+ renderVoteTally(currentSession ? currentSession.log : []);
742
+ });
743
+
744
+ es.addEventListener('status', (e) => {
745
+ const data = JSON.parse(e.data);
746
+ if (currentSession) Object.assign(currentSession, data);
747
+ updateSpeakerBar(data.current_speaker, currentSession ? currentSession.log : []);
748
+ updateRoundProgress(data.current_round, data.max_rounds);
749
+ renderDegradation(data.degradation);
750
+ });
751
+
752
+ es.onerror = () => {
753
+ setConnStatus('disconnected');
754
+ setTimeout(() => {
755
+ if (currentSession) connectSSE(currentSession.id || currentSession.session_id);
756
+ }, 3000);
757
+ };
758
+ }
759
+
760
+ /* ══════════════════════════════════════════════════════
761
+ RENDERING β€” Session List
762
+ ══════════════════════════════════════════════════════ */
763
+ function renderSessionList(list) {
764
+ const grid = document.getElementById('session-grid');
765
+ if (!list || list.length === 0) {
766
+ grid.innerHTML = '<div class="empty">No active sessions</div>';
767
+ return;
768
+ }
769
+
770
+ grid.innerHTML = list.map(s => {
771
+ const pct = s.max_rounds > 0 ? Math.round((s.current_round / s.max_rounds) * 100) : 0;
772
+ const speakers = (s.speakers || []);
773
+ const avatarRow = speakers.slice(0, 8).map(sp =>
774
+ renderAvatar(sp, sp === s.current_speaker ? 'speaking' : 'done', 28)
775
+ ).join('');
776
+ const created = s.created_at ? new Date(s.created_at).toLocaleTimeString() : '';
777
+ const borderCls = s.status === 'active' ? 'active-border' : '';
778
+
779
+ return `<div class="session-card ${borderCls}" onclick="showDetail('${escHtml(s.id || s.session_id)}')">
780
+ <div class="card-header">
781
+ <span class="card-topic">${escHtml(s.topic || 'Untitled')}</span>
782
+ <span class="badge ${escHtml(s.status || 'active')}">${escHtml(s.status || 'active')}</span>
783
+ </div>
784
+ <div class="avatar-row">${avatarRow}</div>
785
+ <div class="progress-wrap">
786
+ <div class="progress-label">
787
+ <span>Round ${s.current_round || 0} / ${s.max_rounds || '?'}</span>
788
+ <span>${pct}%</span>
789
+ </div>
790
+ <div class="progress-track">
791
+ <div class="progress-fill ${s.status === 'synthesized' ? 'green' : ''}" style="width:${pct}%"></div>
792
+ </div>
793
+ </div>
794
+ <div class="card-meta">
795
+ <span>πŸ‘€ ${speakers.length} speaker${speakers.length !== 1 ? 's' : ''}</span>
796
+ <span>πŸ’¬ ${s.log_count || 0} entries</span>
797
+ ${created ? `<span>πŸ• ${created}</span>` : ''}
798
+ </div>
799
+ </div>`;
800
+ }).join('');
801
+ }
802
+
803
+ /* ══════════════════════════════════════════════════════
804
+ RENDERING β€” Session Detail
805
+ ══════════════════════════════════════════════════════ */
806
+ function renderSessionDetail(data) {
807
+ // Chat log
808
+ const log = document.getElementById('chat-log');
809
+ log.innerHTML = '';
810
+ (data.log || []).forEach(entry => addLogEntry(entry, false));
811
+ log.scrollTop = log.scrollHeight;
812
+
813
+ // Speaker bar
814
+ updateSpeakerBar(data.current_speaker, data.log || []);
815
+
816
+ // Round progress
817
+ updateRoundProgress(data.current_round, data.max_rounds);
818
+
819
+ // Vote tally
820
+ renderVoteTally(data.log || []);
821
+
822
+ // Degradation
823
+ renderDegradation(data.degradation);
824
+
825
+ // Roles
826
+ renderRoles(data.roles || data.role_assignments);
827
+
828
+ // Ordering badge
829
+ if (data.ordering_strategy) {
830
+ const h = document.querySelector('#sb-roles h3');
831
+ if (h) h.textContent = `Roles Β· ${data.ordering_strategy}`;
832
+ }
833
+
834
+ // Header title
835
+ document.getElementById('header-title').textContent =
836
+ 'πŸ”­ ' + (data.topic || 'Session');
837
+ }
838
+
839
+ function updateSpeakerBar(currentSpeaker, log) {
840
+ const bar = document.getElementById('speaker-bar');
841
+ if (!currentSession) return;
842
+ const speakers = currentSession.speakers || [];
843
+
844
+ // Build last-response map
845
+ const lastMsg = {};
846
+ (log || []).forEach(e => { if (e.speaker) lastMsg[e.speaker] = e.content || ''; });
847
+
848
+ bar.innerHTML = speakers.map(sp => {
849
+ const isCurrent = sp === currentSpeaker;
850
+ const state = isCurrent ? 'speaking' : (lastMsg[sp] ? 'done' : 'idle');
851
+ const bubble = renderSpeechBubble(lastMsg[sp]);
852
+ return `<div class="speaker-slot ${isCurrent ? 'current-speaker' : ''}">
853
+ <div class="avatar ${state}" style="position:relative">
854
+ ${renderAvatar(sp, state, 40).replace(/^<span[^>]*>/, '').replace(/<\/span>$/, '')}
855
+ ${bubble}
856
+ </div>
857
+ <span class="speaker-name">${escHtml(sp)}</span>
858
+ </div>`;
859
+ }).join('');
860
+ }
861
+
862
+ function updateRoundProgress(current, max) {
863
+ const pct = max > 0 ? Math.round((current / max) * 100) : 0;
864
+ const lbl = document.getElementById('sb-round-label');
865
+ const fill = document.getElementById('sb-round-fill');
866
+ if (lbl) lbl.textContent = `Round ${current || 0} / ${max || '?'}`;
867
+ if (fill) fill.style.width = pct + '%';
868
+ }
869
+
870
+ /* ══════════════════════════════════════════════════════
871
+ RENDERING β€” Chat log entry
872
+ ══════════════════════════════════════════════════════ */
873
+ function addLogEntry(entry, animate = true) {
874
+ const log = document.getElementById('chat-log');
875
+ const el = document.createElement('div');
876
+ el.className = 'log-entry';
877
+ if (!animate) el.style.animation = 'none';
878
+
879
+ const m = getMascot(entry.speaker || '');
880
+ const color = m.color;
881
+ el.style.borderLeft = `3px solid ${color}`;
882
+
883
+ const content = (entry.content || '');
884
+ const preview = content.length > 800 ? content.substring(0, 800) + '\n\n[truncated…]' : content;
885
+
886
+ el.innerHTML = `
887
+ <div class="entry-avatar">${renderAvatar(entry.speaker, 'done', 32)}</div>
888
+ <div class="entry-body">
889
+ <div class="entry-header">
890
+ <span class="entry-speaker" style="color:${color}">${escHtml(entry.speaker || '?')}</span>
891
+ ${entry.round != null ? `<span class="entry-round">Round ${entry.round}</span>` : ''}
892
+ ${entry.role_drift ? `<span class="entry-drift">⚠ drift</span>` : ''}
893
+ </div>
894
+ <div class="entry-content">${escHtml(preview)}</div>
895
+ </div>`;
896
+
897
+ log.appendChild(el);
898
+ if (animate) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
899
+ }
900
+
901
+ /* ══════════════════════════════════════════════════════
902
+ RENDERING β€” Vote tally
903
+ ══════════════════════════════════════════════════════ */
904
+ function renderVoteTally(log) {
905
+ const counts = { agree: 0, disagree: 0, conditional: 0 };
906
+ (log || []).forEach(entry => {
907
+ (entry.votes || []).forEach(v => {
908
+ const key = (v || '').toLowerCase();
909
+ if (key in counts) counts[key]++;
910
+ });
911
+ });
912
+
913
+ const total = counts.agree + counts.disagree + counts.conditional || 1;
914
+ ['agree', 'disagree', 'conditional'].forEach(k => {
915
+ const fill = document.getElementById(`vf-${k}`);
916
+ const count = document.getElementById(`vc-${k}`);
917
+ if (fill) fill.style.width = Math.round((counts[k] / total) * 100) + '%';
918
+ if (count) count.textContent = counts[k];
919
+ });
920
+ }
921
+
922
+ /* ══════════════════════════════════════════════════════
923
+ RENDERING β€” Degradation
924
+ ══════════════════════════════════════════════════════ */
925
+ function renderDegradation(deg) {
926
+ const list = document.getElementById('deg-list');
927
+ if (!list) return;
928
+ if (!deg || typeof deg !== 'object') { list.innerHTML = '<span style="color:var(--muted);font-size:0.8em">No data</span>'; return; }
929
+
930
+ const tiers = { ok: '🟒', warn: '🟑', error: 'πŸ”΄', monitoring: '🟒', browser: '🟑', terminal: 'πŸ”΄' };
931
+ list.innerHTML = Object.entries(deg).map(([key, val]) => {
932
+ const dot = tiers[val] || tiers[key] || 'βšͺ';
933
+ return `<div class="deg-row"><span class="deg-dot">${dot}</span><span class="deg-label">${escHtml(key)}</span><span style="color:var(--muted);font-size:0.75em">${escHtml(String(val))}</span></div>`;
934
+ }).join('');
935
+ }
936
+
937
+ /* ══════════════════════════════════════════════════════
938
+ RENDERING β€” Roles
939
+ ══════════════════════════════════════════════════════ */
940
+ function renderRoles(roles) {
941
+ const list = document.getElementById('roles-list');
942
+ if (!list) return;
943
+ if (!roles || typeof roles !== 'object') { list.innerHTML = '<span style="color:var(--muted);font-size:0.8em">No roles</span>'; return; }
944
+
945
+ list.innerHTML = Object.entries(roles).map(([sp, role]) => `
946
+ <div class="role-item">
947
+ ${renderAvatar(sp, 'done', 20)}
948
+ <span class="role-speaker">${escHtml(sp)}</span>
949
+ <span class="role-name">${escHtml(role)}</span>
950
+ </div>`).join('');
951
+ }
952
+
953
+ /* ══════════════════════════════════════════════════════
954
+ CONNECTION STATUS
955
+ ══════════════════════════════════════════════════════ */
956
+ function setConnStatus(state) {
957
+ const el = document.getElementById('conn-status');
958
+ if (state === 'connected') {
959
+ el.innerHTML = '';
960
+ } else {
961
+ el.innerHTML = `<div class="conn-warn"><span class="conn-dot"></span>Reconnecting…</div>`;
962
+ }
963
+ }
964
+
965
+ /* ══════════════════════════════════════════════════════
966
+ NAVIGATION
967
+ ══════════════════════════════════════════════════════ */
968
+ function showList() {
969
+ if (currentStream) { currentStream.close(); currentStream = null; }
970
+ currentSession = null;
971
+
972
+ document.getElementById('screen-list').classList.add('active');
973
+ document.getElementById('screen-detail').classList.remove('active');
974
+ document.getElementById('screen-config').classList.remove('active');
975
+ document.getElementById('screen-stats').classList.remove('active');
976
+ document.getElementById('btn-back').style.display = 'none';
977
+ document.getElementById('btn-refresh').style.display = '';
978
+ document.getElementById('btn-gear').classList.remove('active');
979
+ document.getElementById('btn-stats').classList.remove('active');
980
+ document.getElementById('header-title').textContent = 'πŸ”­ Deliberation Observer';
981
+ document.getElementById('conn-status').innerHTML = '';
982
+ // Show FAB on list screen
983
+ const fab = document.getElementById('fab-start');
984
+ if (fab) fab.style.display = '';
985
+ previousScreen = 'list';
986
+ loadSessions();
987
+ }
988
+
989
+ function showDetail(sessionId) {
990
+ document.getElementById('screen-list').classList.remove('active');
991
+ document.getElementById('screen-detail').classList.add('active');
992
+ document.getElementById('screen-config').classList.remove('active');
993
+ document.getElementById('screen-stats').classList.remove('active');
994
+ document.getElementById('btn-back').style.display = '';
995
+ document.getElementById('btn-refresh').style.display = 'none';
996
+ document.getElementById('btn-gear').classList.remove('active');
997
+ document.getElementById('btn-stats').classList.remove('active');
998
+ document.getElementById('chat-log').innerHTML = '';
999
+ document.getElementById('speaker-bar').innerHTML = '';
1000
+ // Hide FAB on detail screen
1001
+ const fab = document.getElementById('fab-start');
1002
+ if (fab) fab.style.display = 'none';
1003
+ previousScreen = 'detail';
1004
+
1005
+ // Find session data for initial render while SSE loads
1006
+ const found = sessions.find(s => (s.id || s.session_id) === sessionId);
1007
+ if (found) {
1008
+ currentSession = found;
1009
+ document.getElementById('header-title').textContent = 'πŸ”­ ' + (found.topic || 'Session');
1010
+ }
1011
+
1012
+ connectSSE(sessionId);
1013
+ }
1014
+
1015
+ function showConfig() {
1016
+ // Toggle: if already on config, go back
1017
+ if (document.getElementById('screen-config').classList.contains('active')) {
1018
+ if (previousScreen === 'detail' && currentSession) {
1019
+ const id = currentSession.id || currentSession.session_id;
1020
+ showDetail(id);
1021
+ } else {
1022
+ showList();
1023
+ }
1024
+ return;
1025
+ }
1026
+
1027
+ // Remember where we came from
1028
+ if (document.getElementById('screen-detail').classList.contains('active')) {
1029
+ previousScreen = 'detail';
1030
+ } else {
1031
+ previousScreen = 'list';
1032
+ }
1033
+
1034
+ document.getElementById('screen-list').classList.remove('active');
1035
+ document.getElementById('screen-detail').classList.remove('active');
1036
+ document.getElementById('screen-config').classList.add('active');
1037
+ document.getElementById('screen-stats').classList.remove('active');
1038
+ document.getElementById('btn-back').style.display = '';
1039
+ document.getElementById('btn-refresh').style.display = 'none';
1040
+ document.getElementById('btn-gear').classList.add('active');
1041
+ document.getElementById('btn-stats').classList.remove('active');
1042
+ document.getElementById('header-title').textContent = 'βš™ LLM Configuration';
1043
+ document.getElementById('conn-status').innerHTML = '';
1044
+ // Hide FAB on config screen
1045
+ const fab = document.getElementById('fab-start');
1046
+ if (fab) fab.style.display = 'none';
1047
+
1048
+ loadConfig();
1049
+ }
1050
+
1051
+ function showStats() {
1052
+ // Toggle: if already on stats, go back to list
1053
+ if (document.getElementById('screen-stats').classList.contains('active')) {
1054
+ showList();
1055
+ return;
1056
+ }
1057
+
1058
+ document.getElementById('screen-list').classList.remove('active');
1059
+ document.getElementById('screen-detail').classList.remove('active');
1060
+ document.getElementById('screen-config').classList.remove('active');
1061
+ document.getElementById('screen-stats').classList.add('active');
1062
+ document.getElementById('btn-back').style.display = '';
1063
+ document.getElementById('btn-refresh').style.display = 'none';
1064
+ document.getElementById('btn-gear').classList.remove('active');
1065
+ document.getElementById('btn-stats').classList.add('active');
1066
+ document.getElementById('header-title').textContent = 'πŸ“Š LLM Statistics';
1067
+ document.getElementById('conn-status').innerHTML = '';
1068
+ // Hide FAB on stats screen
1069
+ const fab = document.getElementById('fab-start');
1070
+ if (fab) fab.style.display = 'none';
1071
+
1072
+ previousScreen = 'list';
1073
+ loadStats();
1074
+ }
1075
+
1076
+ // Override Back button behavior to respect all screens
1077
+ document.getElementById('btn-back').addEventListener('click', function(e) {
1078
+ e.stopPropagation();
1079
+ if (document.getElementById('screen-stats').classList.contains('active')) {
1080
+ showList();
1081
+ } else if (document.getElementById('screen-config').classList.contains('active')) {
1082
+ if (previousScreen === 'detail' && currentSession) {
1083
+ const id = currentSession.id || currentSession.session_id;
1084
+ showDetail(id);
1085
+ } else {
1086
+ showList();
1087
+ }
1088
+ } else {
1089
+ showList();
1090
+ }
1091
+ });
1092
+
1093
+ /* ══════════════════════════════════════════════════════
1094
+ UTILITIES
1095
+ ══════════════════════════════════════════════════════ */
1096
+ function escHtml(s) {
1097
+ return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1098
+ }
1099
+
1100
+ /* ══════════════════════════════════════════════════════
1101
+ LLM CONFIG β€” Master list
1102
+ ══════════════════════════════════════════════════════ */
1103
+ const LLM_LIST = [
1104
+ { key: 'claude', name: 'Claude', label: 'Claude', cmd: 'claude' },
1105
+ { key: 'codex', name: 'Codex', label: 'Codex', cmd: 'codex' },
1106
+ { key: 'gemini', name: 'Gemini', label: 'Gemini', cmd: 'gemini' },
1107
+ { key: 'qwen', name: 'Qwen', label: 'Qwen', cmd: 'qwen' },
1108
+ { key: 'chatgpt', name: 'ChatGPT', label: 'ChatGPT', cmd: 'chatgpt' },
1109
+ { key: 'aider', name: 'Aider', label: 'Aider', cmd: 'aider' },
1110
+ { key: 'llm', name: 'LLM (Simon)', label: 'LLM', cmd: 'llm' },
1111
+ { key: 'opencode', name: 'OpenCode', label: 'OpenCode', cmd: 'opencode' },
1112
+ { key: 'cursor', name: 'Cursor', label: 'Cursor', cmd: 'cursor' },
1113
+ { key: 'continue', name: 'Continue', label: 'Continue', cmd: 'continue' },
1114
+ ];
1115
+
1116
+ // Extend MASCOTS with any missing entries
1117
+ Object.assign(MASCOTS, {
1118
+ aider: { initial: 'Ai', color: '#E44D26', shape: 'circle' },
1119
+ llm: { initial: 'L', color: '#5B6EAE', shape: 'square' },
1120
+ opencode: { initial: 'Oc', color: '#00C7B7', shape: 'hexagon' },
1121
+ cursor: { initial: 'Cr', color: '#6E6E8A', shape: 'circle' },
1122
+ continue: { initial: 'Co', color: '#7C3AED', shape: 'diamond' },
1123
+ });
1124
+
1125
+ let configState = []; // array of enabled LLM keys
1126
+
1127
+ /* ══════════════════════════════════════════════════════
1128
+ LLM CONFIG β€” API
1129
+ ══════════════════════════════════════════════════════ */
1130
+ async function loadConfig() {
1131
+ try {
1132
+ const res = await fetch('/api/config');
1133
+ const data = await res.json();
1134
+ configState = Array.isArray(data.enabled_clis) ? data.enabled_clis : [];
1135
+ } catch (e) {
1136
+ configState = [];
1137
+ }
1138
+ renderLlmGrid();
1139
+ updateModeBadge();
1140
+ }
1141
+
1142
+ async function saveConfig() {
1143
+ const enabled = LLM_LIST
1144
+ .filter(llm => {
1145
+ const el = document.getElementById(`toggle-${llm.key}`);
1146
+ return el && el.checked;
1147
+ })
1148
+ .map(llm => llm.key);
1149
+
1150
+ try {
1151
+ const res = await fetch('/api/config', {
1152
+ method: 'POST',
1153
+ headers: { 'Content-Type': 'application/json' },
1154
+ body: JSON.stringify({ enabled_clis: enabled }),
1155
+ });
1156
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1157
+ configState = enabled;
1158
+ updateModeBadge();
1159
+ showToast('Configuration saved', 'success');
1160
+ } catch (e) {
1161
+ showToast('Save failed: ' + e.message, 'error');
1162
+ }
1163
+ }
1164
+
1165
+ async function resetConfig() {
1166
+ try {
1167
+ const res = await fetch('/api/config', {
1168
+ method: 'POST',
1169
+ headers: { 'Content-Type': 'application/json' },
1170
+ body: JSON.stringify({ enabled_clis: [] }),
1171
+ });
1172
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1173
+ configState = [];
1174
+ renderLlmGrid();
1175
+ updateModeBadge();
1176
+ showToast('Reset to Auto-detect', 'success');
1177
+ } catch (e) {
1178
+ showToast('Reset failed: ' + e.message, 'error');
1179
+ }
1180
+ }
1181
+
1182
+ /* ══════════════════════════════════════════════════════
1183
+ LLM CONFIG β€” Rendering
1184
+ ══════════════════════════════════════════════════════ */
1185
+ function renderLlmGrid() {
1186
+ const grid = document.getElementById('llm-grid');
1187
+ if (!grid) return;
1188
+
1189
+ grid.innerHTML = LLM_LIST.map((llm, i) => {
1190
+ const isEnabled = configState.includes(llm.key);
1191
+ const cardCls = isEnabled ? 'enabled' : 'disabled';
1192
+ const delay = i * 40;
1193
+ const avatar = renderAvatar(llm.key, 'done', 48);
1194
+
1195
+ return `<div class="llm-card ${cardCls}" id="card-${llm.key}"
1196
+ style="animation-delay:${delay}ms"
1197
+ onclick="toggleLlmCard('${llm.key}')">
1198
+ ${avatar}
1199
+ <span class="llm-card-name">
1200
+ ${escHtml(llm.name)}<span class="cli-status-dot loading" id="dot-${llm.key}" title="Checking..."></span>
1201
+ </span>
1202
+ <span class="llm-card-cmd">${escHtml(llm.cmd)}</span>
1203
+ <div class="toggle-wrap">
1204
+ <label class="toggle" onclick="event.stopPropagation()">
1205
+ <input type="checkbox" id="toggle-${llm.key}"
1206
+ ${isEnabled ? 'checked' : ''}
1207
+ onchange="syncCardState('${llm.key}')">
1208
+ <span class="toggle-track"></span>
1209
+ <span class="toggle-thumb"></span>
1210
+ </label>
1211
+ </div>
1212
+ </div>`;
1213
+ }).join('');
1214
+
1215
+ loadCliStatus();
1216
+ }
1217
+
1218
+ function toggleLlmCard(key) {
1219
+ const checkbox = document.getElementById(`toggle-${key}`);
1220
+ if (!checkbox) return;
1221
+ checkbox.checked = !checkbox.checked;
1222
+ syncCardState(key);
1223
+ }
1224
+
1225
+ function syncCardState(key) {
1226
+ const checkbox = document.getElementById(`toggle-${key}`);
1227
+ const card = document.getElementById(`card-${key}`);
1228
+ if (!checkbox || !card) return;
1229
+ if (checkbox.checked) {
1230
+ card.classList.add('enabled');
1231
+ card.classList.remove('disabled');
1232
+ } else {
1233
+ card.classList.remove('enabled');
1234
+ card.classList.add('disabled');
1235
+ }
1236
+ updateModeBadge();
1237
+ }
1238
+
1239
+ function updateModeBadge() {
1240
+ const badge = document.getElementById('config-mode-badge');
1241
+ if (!badge) return;
1242
+ const checked = LLM_LIST.filter(llm => {
1243
+ const el = document.getElementById(`toggle-${llm.key}`);
1244
+ return el && el.checked;
1245
+ });
1246
+ const isAuto = checked.length === 0;
1247
+ badge.textContent = isAuto ? 'Auto-detect' : `Custom (${checked.length} selected)`;
1248
+ badge.classList.toggle('auto', isAuto);
1249
+ }
1250
+
1251
+ /* ══════════════════════════════════════════════════════
1252
+ TOAST
1253
+ ══════════════════════════════════════════════════════ */
1254
+ let toastTimer = null;
1255
+ function showToast(message, type = 'success') {
1256
+ // Remove any existing toast
1257
+ const existing = document.getElementById('toast-el');
1258
+ if (existing) existing.remove();
1259
+ if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
1260
+
1261
+ const toast = document.createElement('div');
1262
+ toast.id = 'toast-el';
1263
+ toast.className = `toast ${type}`;
1264
+ toast.textContent = (type === 'success' ? 'βœ“ ' : 'βœ• ') + message;
1265
+ document.body.appendChild(toast);
1266
+
1267
+ toastTimer = setTimeout(() => {
1268
+ toast.classList.add('hiding');
1269
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, 380);
1270
+ }, 3000);
1271
+ }
1272
+
1273
+ /* ══════════════════════════════════════════════════════
1274
+ FEATURE 1: CLI Install Status
1275
+ ══════════════════════════════════════════════════════ */
1276
+ async function loadCliStatus() {
1277
+ try {
1278
+ const res = await fetch('/api/cli-status');
1279
+ const data = await res.json();
1280
+ const statusMap = {};
1281
+ (data.clis || []).forEach(c => { statusMap[c.name] = c.installed; });
1282
+ LLM_LIST.forEach(llm => {
1283
+ const dot = document.getElementById(`dot-${llm.key}`);
1284
+ if (!dot) return;
1285
+ const installed = statusMap[llm.key] === true;
1286
+ dot.className = 'cli-status-dot ' + (installed ? 'installed' : 'not-installed');
1287
+ dot.title = installed ? 'Installed' : 'Not installed';
1288
+ });
1289
+ } catch (e) {
1290
+ // On error, leave dots as loading (gray) β€” non-critical
1291
+ }
1292
+ }
1293
+
1294
+ /* ══════════════════════════════════════════════════════
1295
+ FEATURE 2: Browser LLM Tabs
1296
+ ══════════════════════════════════════════════════════ */
1297
+ function toggleBrowserTabs() {
1298
+ const body = document.getElementById('browser-tabs-body');
1299
+ const toggle = document.getElementById('browser-toggle');
1300
+ const isOpen = body.style.display !== 'none';
1301
+ body.style.display = isOpen ? 'none' : 'block';
1302
+ toggle.classList.toggle('open', !isOpen);
1303
+ if (!isOpen) loadBrowserTabs();
1304
+ }
1305
+
1306
+ async function loadBrowserTabs() {
1307
+ const list = document.getElementById('browser-tabs-list');
1308
+ list.innerHTML = '<div class="cdp-status">Scanning...</div>';
1309
+ try {
1310
+ const res = await fetch('/api/browser-tabs');
1311
+ const data = await res.json();
1312
+ if (!data.cdp_available) {
1313
+ list.innerHTML = `<div class="cdp-status unavailable">⚠ ${escHtml(data.message || 'CDP not available')}</div>`;
1314
+ return;
1315
+ }
1316
+ if (!data.tabs || data.tabs.length === 0) {
1317
+ list.innerHTML = '<div class="cdp-status available">βœ“ CDP connected β€” No LLM tabs detected</div>';
1318
+ return;
1319
+ }
1320
+ list.innerHTML = `<div class="cdp-status available">βœ“ CDP connected β€” ${data.tabs.length} LLM tab(s)</div>` +
1321
+ data.tabs.map(t => `<div class="browser-tab-item">
1322
+ <span class="tab-title">${escHtml(t.title || '')}</span>
1323
+ <span class="tab-url">${escHtml(t.url || '')}</span>
1324
+ </div>`).join('');
1325
+ } catch (e) {
1326
+ list.innerHTML = `<div class="cdp-status unavailable">Error: ${escHtml(e.message)}</div>`;
1327
+ }
1328
+ }
1329
+
1330
+ /* ══════════════════════════════════════════════════════
1331
+ FEATURE 3: Quick Start Deliberation
1332
+ ══════════════════════════════════════════════════════ */
1333
+ let quickStartSpeakers = new Set();
1334
+
1335
+ function showQuickStart() {
1336
+ document.getElementById('quick-start-modal').style.display = 'flex';
1337
+ // Pre-select from config (enabled CLIs), or default to first 3
1338
+ quickStartSpeakers = new Set(
1339
+ configState.length > 0
1340
+ ? configState
1341
+ : LLM_LIST.slice(0, 3).map(l => l.key)
1342
+ );
1343
+ renderSpeakerChips();
1344
+ }
1345
+
1346
+ function hideQuickStart() {
1347
+ document.getElementById('quick-start-modal').style.display = 'none';
1348
+ }
1349
+
1350
+ function renderSpeakerChips() {
1351
+ const container = document.getElementById('qs-speakers');
1352
+ container.innerHTML = LLM_LIST.map(llm => {
1353
+ const sel = quickStartSpeakers.has(llm.key) ? 'selected' : '';
1354
+ return `<span class="speaker-chip ${sel}" onclick="toggleSpeakerChip('${llm.key}')">
1355
+ ${renderAvatar(llm.key, 'done', 20)} ${escHtml(llm.label)}
1356
+ </span>`;
1357
+ }).join('');
1358
+ }
1359
+
1360
+ function toggleSpeakerChip(key) {
1361
+ if (quickStartSpeakers.has(key)) quickStartSpeakers.delete(key);
1362
+ else quickStartSpeakers.add(key);
1363
+ renderSpeakerChips();
1364
+ }
1365
+
1366
+ async function startDeliberation() {
1367
+ const topic = document.getElementById('qs-topic').value.trim();
1368
+ const rounds = parseInt(document.getElementById('qs-rounds').value) || 3;
1369
+ const speakers = [...quickStartSpeakers];
1370
+ if (!topic) { showToast('Please enter a topic', 'error'); return; }
1371
+ if (speakers.length < 2) { showToast('Select at least 2 speakers', 'error'); return; }
1372
+ try {
1373
+ const res = await fetch('/api/sessions/start', {
1374
+ method: 'POST',
1375
+ headers: { 'Content-Type': 'application/json' },
1376
+ body: JSON.stringify({ topic, speakers, max_rounds: rounds }),
1377
+ });
1378
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1379
+ const session = await res.json();
1380
+ hideQuickStart();
1381
+ showToast(`Deliberation started: ${topic}`, 'success');
1382
+ await loadSessions();
1383
+ showDetail(session.id || session.session_id);
1384
+ } catch (e) {
1385
+ showToast('Failed to start: ' + e.message, 'error');
1386
+ }
1387
+ }
1388
+
1389
+ // Close modal on Escape key
1390
+ document.addEventListener('keydown', function(e) {
1391
+ if (e.key === 'Escape') hideQuickStart();
1392
+ });
1393
+
1394
+ /* ══════════════════════════════════════════════════════
1395
+ FEATURE 4: LLM Statistics Dashboard
1396
+ ══════════════════════════════════════════════════════ */
1397
+ async function loadStats() {
1398
+ const summary = document.getElementById('stats-summary');
1399
+ const speakers = document.getElementById('stats-speakers');
1400
+ summary.innerHTML = '<div class="empty"><div class="spinner"></div>Loading…</div>';
1401
+ speakers.innerHTML = '';
1402
+ try {
1403
+ const res = await fetch('/api/stats');
1404
+ const data = await res.json();
1405
+ renderStats(data);
1406
+ } catch (e) {
1407
+ summary.innerHTML = `<div class="empty">Failed to load stats: ${escHtml(e.message)}</div>`;
1408
+ speakers.innerHTML = '';
1409
+ }
1410
+ }
1411
+
1412
+ function renderStats(data) {
1413
+ const summary = document.getElementById('stats-summary');
1414
+ const container = document.getElementById('stats-speakers');
1415
+
1416
+ const speakerEntries = Object.entries(data.speakers || {});
1417
+ summary.innerHTML = `
1418
+ <div class="stat-card" style="animation-delay:0ms">
1419
+ <div class="stat-value">${data.total_sessions != null ? data.total_sessions : 'β€”'}</div>
1420
+ <div class="stat-label">Sessions</div>
1421
+ </div>
1422
+ <div class="stat-card" style="animation-delay:60ms">
1423
+ <div class="stat-value">${data.total_turns != null ? data.total_turns : 'β€”'}</div>
1424
+ <div class="stat-label">Total Turns</div>
1425
+ </div>
1426
+ <div class="stat-card" style="animation-delay:120ms">
1427
+ <div class="stat-value">${speakerEntries.length}</div>
1428
+ <div class="stat-label">Speakers</div>
1429
+ </div>
1430
+ `;
1431
+
1432
+ if (speakerEntries.length === 0) {
1433
+ container.innerHTML = '<div class="empty">No participation data yet</div>';
1434
+ return;
1435
+ }
1436
+
1437
+ const sorted = speakerEntries.sort((a, b) => b[1].turns - a[1].turns);
1438
+ const maxTurns = sorted[0][1].turns || 1;
1439
+
1440
+ container.innerHTML = sorted.map(([name, s], i) => {
1441
+ const pct = Math.round(((s.turns || 0) / maxTurns) * 100);
1442
+ const m = getMascot(name);
1443
+ const votes = s.votes || {};
1444
+ const voteStr = [
1445
+ votes.agree > 0 ? `βœ…${votes.agree}` : '',
1446
+ votes.disagree > 0 ? `❌${votes.disagree}` : '',
1447
+ votes.conditional > 0 ? `🟑${votes.conditional}` : '',
1448
+ ].filter(Boolean).join(' ') || 'No votes';
1449
+
1450
+ return `<div class="speaker-stat-row" style="animation-delay:${i * 0.05}s">
1451
+ ${renderAvatar(name, 'done', 40)}
1452
+ <div class="speaker-stat-info">
1453
+ <div class="speaker-stat-name">${escHtml(name)}</div>
1454
+ <div class="speaker-stat-detail">${s.turns || 0} turns Β· avg ${s.avg_length || 0} chars Β· ${voteStr}</div>
1455
+ </div>
1456
+ <div class="speaker-stat-bar">
1457
+ <div style="font-size:0.75em;color:var(--muted);text-align:right">${s.turns || 0}</div>
1458
+ <div class="mini-bar">
1459
+ <div class="mini-bar-fill" style="width:${pct}%;background:${escHtml(m.color)}"></div>
1460
+ </div>
1461
+ </div>
1462
+ </div>`;
1463
+ }).join('');
1464
+ }
1465
+
1466
+ /* ══════════════════════════════════════════════════════
1467
+ INIT
1468
+ ══════════════════════════════════════════════════════ */
1469
+ loadSessions();
1470
+ setInterval(() => {
1471
+ // Only auto-refresh the list when on the list screen
1472
+ if (document.getElementById('screen-list').classList.contains('active')) {
1473
+ loadSessions();
1474
+ }
1475
+ }, 5000);
1476
+ </script>
1477
+ </body>
1478
+ </html>