wop commited on
Commit
c023aad
·
verified ·
1 Parent(s): 9b6863f

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +22 -0
  2. main.py +472 -0
  3. requirements.txt +8 -0
  4. templates/index.html +1148 -0
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV DATA_DIR=/app/data
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ COPY requirements.txt /app/requirements.txt
14
+ RUN pip install --no-cache-dir -r /app/requirements.txt
15
+
16
+ COPY main.py /app/main.py
17
+ COPY templates /app/templates
18
+ COPY data /app/data
19
+
20
+ EXPOSE 7860
21
+
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import numpy as np
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ # Optional but recommended for similarity search.
17
+ # If sentence-transformers is unavailable, the app still works with a fallback.
18
+ try:
19
+ from sentence_transformers import SentenceTransformer
20
+ except Exception: # pragma: no cover
21
+ SentenceTransformer = None # type: ignore
22
+
23
+ try:
24
+ from sklearn.metrics.pairwise import cosine_similarity
25
+ except Exception: # pragma: no cover
26
+ cosine_similarity = None # type: ignore
27
+
28
+
29
+ APP_TITLE = "Human Intelligence"
30
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
31
+ THREADS_DIR = DATA_DIR / "threads"
32
+ INDEX_FILE = DATA_DIR / "index.json"
33
+ EMBED_FILE = DATA_DIR / "embeddings.json"
34
+ TEMPLATES_DIR = Path("/app/templates")
35
+ SIMILARITY_THRESHOLD = float(os.environ.get("SIMILARITY_THRESHOLD", "0.82"))
36
+
37
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
38
+ THREADS_DIR.mkdir(parents=True, exist_ok=True)
39
+ TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
40
+
41
+ app = FastAPI(title=APP_TITLE)
42
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
43
+
44
+ # Serve any public assets you may add later.
45
+ app.mount("/data", StaticFiles(directory=str(DATA_DIR)), name="data")
46
+
47
+ _embed_model = None
48
+
49
+
50
+ # ---------------------------------------------------------------------
51
+ # Utilities
52
+ # ---------------------------------------------------------------------
53
+ def now_iso() -> str:
54
+ return datetime.now(timezone.utc).isoformat()
55
+
56
+
57
+ def read_json(path: Path, default: Any):
58
+ if not path.exists():
59
+ return default
60
+ try:
61
+ return json.loads(path.read_text(encoding="utf-8"))
62
+ except Exception:
63
+ return default
64
+
65
+
66
+ def write_json(path: Path, data: Any) -> None:
67
+ tmp = path.with_suffix(path.suffix + ".tmp")
68
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
69
+ tmp.replace(path)
70
+
71
+
72
+ def get_username(request: Request, payload: dict | None = None) -> str:
73
+ """
74
+ Priority:
75
+ 1) X-User header
76
+ 2) JSON payload username
77
+ 3) guest
78
+ This keeps the app simple and deployment-friendly.
79
+ """
80
+ header_name = request.headers.get("x-user", "").strip()
81
+ if header_name:
82
+ return header_name
83
+
84
+ if payload:
85
+ p_name = str(payload.get("username", "")).strip()
86
+ if p_name:
87
+ return p_name
88
+
89
+ return "Guest"
90
+
91
+
92
+ # ---------------------------------------------------------------------
93
+ # Embeddings / similarity
94
+ # ---------------------------------------------------------------------
95
+ def load_embed_model():
96
+ global _embed_model
97
+ if _embed_model is None:
98
+ if SentenceTransformer is None:
99
+ return None
100
+ _embed_model = SentenceTransformer("all-MiniLM-L6-v2")
101
+ return _embed_model
102
+
103
+
104
+ def embed_text(text: str) -> list[float]:
105
+ model = load_embed_model()
106
+ if model is None:
107
+ return []
108
+
109
+ vec = model.encode(text, normalize_embeddings=True)
110
+ return vec.tolist()
111
+
112
+
113
+ def load_embed_index() -> dict:
114
+ return read_json(EMBED_FILE, {})
115
+
116
+
117
+ def save_embed_index(idx: dict) -> None:
118
+ write_json(EMBED_FILE, idx)
119
+
120
+
121
+ def find_similar_thread(question: str) -> Optional[tuple[str, float]]:
122
+ idx = load_embed_index()
123
+ if not idx:
124
+ return None
125
+
126
+ if cosine_similarity is None:
127
+ return None
128
+
129
+ q_vec = embed_text(question)
130
+ if not q_vec:
131
+ return None
132
+
133
+ ids = list(idx.keys())
134
+ vecs = np.array([idx[tid]["vector"] for tid in ids], dtype=float)
135
+
136
+ if vecs.size == 0:
137
+ return None
138
+
139
+ sims = cosine_similarity(np.array(q_vec, dtype=float).reshape(1, -1), vecs)[0]
140
+ best_i = int(np.argmax(sims))
141
+ score = float(sims[best_i])
142
+
143
+ if score >= SIMILARITY_THRESHOLD:
144
+ return ids[best_i], score
145
+ return None
146
+
147
+
148
+ # ---------------------------------------------------------------------
149
+ # Storage
150
+ # ---------------------------------------------------------------------
151
+ def load_index() -> list[dict]:
152
+ return read_json(INDEX_FILE, [])
153
+
154
+
155
+ def save_index(idx: list[dict]) -> None:
156
+ write_json(INDEX_FILE, idx)
157
+
158
+
159
+ def thread_path(tid: str) -> Path:
160
+ return THREADS_DIR / f"{tid}.json"
161
+
162
+
163
+ def load_thread(tid: str) -> Optional[dict]:
164
+ return read_json(thread_path(tid), None)
165
+
166
+
167
+ def save_thread(thread: dict) -> None:
168
+ write_json(thread_path(thread["id"]), thread)
169
+
170
+
171
+ def ensure_thread_schema(thread: dict) -> dict:
172
+ thread.setdefault("id", uuid.uuid4().hex)
173
+ thread.setdefault("question", "")
174
+ thread.setdefault("author", "Guest")
175
+ thread.setdefault("created_at", now_iso())
176
+ thread.setdefault("messages", [])
177
+ return thread
178
+
179
+
180
+ # ---------------------------------------------------------------------
181
+ # Content safety
182
+ # ---------------------------------------------------------------------
183
+ _toxic_pipe = None
184
+
185
+ def load_safety_pipe():
186
+ global _toxic_pipe
187
+ if _toxic_pipe is None:
188
+ try:
189
+ from transformers import pipeline
190
+ _toxic_pipe = pipeline(
191
+ "text-classification",
192
+ model="unitary/toxic-bert",
193
+ device=-1,
194
+ top_k=None,
195
+ )
196
+ except Exception:
197
+ _toxic_pipe = False
198
+ return _toxic_pipe
199
+
200
+
201
+ def is_safe(text: str) -> tuple[bool, str]:
202
+ if not text or not text.strip():
203
+ return False, "empty"
204
+
205
+ pipe = load_safety_pipe()
206
+ if pipe is False:
207
+ return True, "ok"
208
+
209
+ try:
210
+ results = pipe(text[:512])[0]
211
+ for r in results:
212
+ label = str(r.get("label", "")).lower()
213
+ score = float(r.get("score", 0.0))
214
+ if label != "non-toxic" and score > 0.70:
215
+ return False, label or "unsafe"
216
+ return True, "ok"
217
+ except Exception:
218
+ return True, "ok"
219
+
220
+
221
+ # ---------------------------------------------------------------------
222
+ # Core operations
223
+ # ---------------------------------------------------------------------
224
+ def create_thread(question: str, author: str) -> tuple[Optional[dict], str]:
225
+ ok, reason = is_safe(question)
226
+ if not ok:
227
+ return None, f"Blocked: content flagged as {reason}."
228
+
229
+ tid = uuid.uuid4().hex
230
+ thread = {
231
+ "id": tid,
232
+ "question": question,
233
+ "author": author,
234
+ "created_at": now_iso(),
235
+ "messages": [],
236
+ }
237
+ save_thread(thread)
238
+
239
+ idx = load_index()
240
+ idx.insert(
241
+ 0,
242
+ {
243
+ "id": tid,
244
+ "title": question[:120],
245
+ "created_at": thread["created_at"],
246
+ "author": author,
247
+ "reply_count": 0,
248
+ },
249
+ )
250
+ save_index(idx)
251
+
252
+ emb_idx = load_embed_index()
253
+ vec = embed_text(question)
254
+ if vec:
255
+ emb_idx[tid] = {"question": question, "vector": vec}
256
+ save_embed_index(emb_idx)
257
+
258
+ return thread, "ok"
259
+
260
+
261
+ def add_answer(tid: str, text: str, author: str) -> tuple[Optional[dict], str]:
262
+ ok, reason = is_safe(text)
263
+ if not ok:
264
+ return None, f"Blocked: content flagged as {reason}."
265
+
266
+ thread = load_thread(tid)
267
+ if thread is None:
268
+ return None, "Thread not found."
269
+
270
+ version = {
271
+ "id": uuid.uuid4().hex,
272
+ "text": text,
273
+ "author": author,
274
+ "created_at": now_iso(),
275
+ "votes": 0,
276
+ "voters": [],
277
+ }
278
+ message = {
279
+ "id": uuid.uuid4().hex,
280
+ "versions": [version],
281
+ "active_version": version["id"],
282
+ "created_at": version["created_at"],
283
+ }
284
+ thread["messages"].append(message)
285
+ save_thread(thread)
286
+
287
+ idx = load_index()
288
+ for entry in idx:
289
+ if entry["id"] == tid:
290
+ entry["reply_count"] = len(thread["messages"])
291
+ break
292
+ save_index(idx)
293
+
294
+ return thread, "ok"
295
+
296
+
297
+ def propose_version(tid: str, msg_id: str, text: str, author: str) -> tuple[Optional[dict], str]:
298
+ ok, reason = is_safe(text)
299
+ if not ok:
300
+ return None, f"Blocked: content flagged as {reason}."
301
+
302
+ thread = load_thread(tid)
303
+ if thread is None:
304
+ return None, "Thread not found."
305
+
306
+ for msg in thread["messages"]:
307
+ if msg["id"] == msg_id:
308
+ version = {
309
+ "id": uuid.uuid4().hex,
310
+ "text": text,
311
+ "author": author,
312
+ "created_at": now_iso(),
313
+ "votes": 0,
314
+ "voters": [],
315
+ }
316
+ msg["versions"].append(version)
317
+ save_thread(thread)
318
+ return thread, "ok"
319
+
320
+ return None, "Message not found."
321
+
322
+
323
+ def vote_version(tid: str, msg_id: str, version_id: str, username: str) -> tuple[Optional[dict], str]:
324
+ thread = load_thread(tid)
325
+ if thread is None:
326
+ return None, "Thread not found."
327
+
328
+ for msg in thread["messages"]:
329
+ if msg["id"] == msg_id:
330
+ for v in msg["versions"]:
331
+ if v["id"] == version_id:
332
+ if username in v["voters"]:
333
+ return thread, "already_voted"
334
+ v["votes"] += 1
335
+ v["voters"].append(username)
336
+ break
337
+
338
+ msg["active_version"] = max(msg["versions"], key=lambda x: x["votes"])["id"]
339
+ save_thread(thread)
340
+ return thread, "ok"
341
+
342
+ return None, "Message not found."
343
+
344
+
345
+ def get_active_version(msg: dict) -> dict:
346
+ vid = msg.get("active_version")
347
+ for v in msg.get("versions", []):
348
+ if v.get("id") == vid:
349
+ return v
350
+ return msg.get("versions", [{}])[0]
351
+
352
+
353
+ # ---------------------------------------------------------------------
354
+ # API
355
+ # ---------------------------------------------------------------------
356
+ @app.get("/", response_class=HTMLResponse)
357
+ def home(request: Request):
358
+ return templates.TemplateResponse(
359
+ "index.html",
360
+ {
361
+ "request": request,
362
+ "app_title": APP_TITLE,
363
+ "init_json": json.dumps(
364
+ {
365
+ "ok": True,
366
+ "username": get_username(request),
367
+ "threads": load_index(),
368
+ },
369
+ ensure_ascii=False,
370
+ ),
371
+ },
372
+ )
373
+
374
+
375
+ @app.get("/api/init")
376
+ def api_init(request: Request):
377
+ return JSONResponse(
378
+ {
379
+ "ok": True,
380
+ "username": get_username(request),
381
+ "threads": load_index(),
382
+ }
383
+ )
384
+
385
+
386
+ @app.post("/api")
387
+ async def api(request: Request):
388
+ try:
389
+ payload = await request.json()
390
+ except Exception:
391
+ return JSONResponse({"ok": False, "error": "bad payload"})
392
+
393
+ action = str(payload.get("action", ""))
394
+ username = get_username(request, payload)
395
+
396
+ if action == "init":
397
+ return JSONResponse({"ok": True, "username": username, "threads": load_index()})
398
+
399
+ if action == "list_threads":
400
+ return JSONResponse({"ok": True, "threads": load_index()})
401
+
402
+ if action == "get_thread":
403
+ tid = str(payload.get("thread_id", ""))
404
+ thread = load_thread(tid)
405
+ if thread is None:
406
+ return JSONResponse({"ok": False, "error": "not found"})
407
+ return JSONResponse({"ok": True, "thread": thread})
408
+
409
+ if action == "new_thread":
410
+ if not username or username == "Guest":
411
+ return JSONResponse({"ok": False, "error": "not signed in"})
412
+ question = str(payload.get("question", "")).strip()
413
+ if not question:
414
+ return JSONResponse({"ok": False, "error": "empty question"})
415
+ sim = find_similar_thread(question)
416
+ thread, msg = create_thread(question, username)
417
+ if thread is None:
418
+ return JSONResponse({"ok": False, "error": msg})
419
+ return JSONResponse({"ok": True, "thread": thread, "similar": sim is not None})
420
+
421
+ if action == "add_answer":
422
+ if not username or username == "Guest":
423
+ return JSONResponse({"ok": False, "error": "not signed in"})
424
+ tid = str(payload.get("thread_id", ""))
425
+ text = str(payload.get("text", "")).strip()
426
+ if not text:
427
+ return JSONResponse({"ok": False, "error": "empty answer"})
428
+ thread, msg = add_answer(tid, text, username)
429
+ if thread is None:
430
+ return JSONResponse({"ok": False, "error": msg})
431
+ return JSONResponse({"ok": True, "thread": thread})
432
+
433
+ if action == "propose":
434
+ if not username or username == "Guest":
435
+ return JSONResponse({"ok": False, "error": "not signed in"})
436
+ tid = str(payload.get("thread_id", ""))
437
+ msg_id = str(payload.get("msg_id", ""))
438
+ text = str(payload.get("text", "")).strip()
439
+ if not text:
440
+ return JSONResponse({"ok": False, "error": "empty proposal"})
441
+ thread, msg = propose_version(tid, msg_id, text, username)
442
+ if thread is None:
443
+ return JSONResponse({"ok": False, "error": msg})
444
+ return JSONResponse({"ok": True, "thread": thread})
445
+
446
+ if action == "vote":
447
+ if not username or username == "Guest":
448
+ return JSONResponse({"ok": False, "error": "not signed in"})
449
+ tid = str(payload.get("thread_id", ""))
450
+ msg_id = str(payload.get("msg_id", ""))
451
+ version_id = str(payload.get("version_id", ""))
452
+ thread, msg = vote_version(tid, msg_id, version_id, username)
453
+ if thread is None:
454
+ return JSONResponse({"ok": False, "error": msg})
455
+ if msg == "already_voted":
456
+ return JSONResponse({"ok": False, "error": "already voted"})
457
+ return JSONResponse({"ok": True, "thread": thread})
458
+
459
+ return JSONResponse({"ok": False, "error": f"unknown action: {action}"})
460
+
461
+
462
+ # ---------------------------------------------------------------------
463
+ # Optional healthcheck
464
+ # ---------------------------------------------------------------------
465
+ @app.get("/health")
466
+ def health():
467
+ return {"ok": True}
468
+
469
+
470
+ if __name__ == "__main__":
471
+ import uvicorn
472
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ jinja2==3.1.5
4
+ numpy==2.2.1
5
+ scikit-learn==1.6.0
6
+ sentence-transformers==3.3.1
7
+ transformers==4.48.0
8
+ torch==2.5.1
templates/index.html ADDED
@@ -0,0 +1,1148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>