lvwerra HF Staff commited on
Commit
3ce5b7e
Β·
verified Β·
1 Parent(s): e4eb172

OAuth login (hf_oauth + write-repos), reversed feed, composer-at-top, wider chat (570px)

Browse files
Files changed (4) hide show
  1. README.md +5 -0
  2. app.py +172 -20
  3. requirements.txt +1 -0
  4. static/index.html +126 -68
README.md CHANGED
@@ -7,6 +7,11 @@ sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  short_description: Dashboard for the Hutter Prize (100MB) collab
 
 
 
 
 
10
  ---
11
 
12
  # Hutter Prize (100MB) β€” Live
 
7
  app_port: 7860
8
  pinned: false
9
  short_description: Dashboard for the Hutter Prize (100MB) collab
10
+ hf_oauth: true
11
+ hf_oauth_authorized_org: ml-intern-explorers
12
+ hf_oauth_scopes:
13
+ - write-repos
14
+ hf_oauth_expiration_minutes: 43200
15
  ---
16
 
17
  # Hutter Prize (100MB) β€” Live
app.py CHANGED
@@ -28,17 +28,20 @@ import asyncio
28
  import logging
29
  import os
30
  import re
 
31
  from contextlib import asynccontextmanager
32
  from datetime import datetime, timezone
33
  from pathlib import Path
34
  from typing import Any
 
35
  from uuid import uuid4
36
 
37
  import httpx
38
- from fastapi import FastAPI, HTTPException
39
- from fastapi.responses import Response
40
  from fastapi.staticfiles import StaticFiles
41
  from pydantic import BaseModel, Field
 
42
 
43
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
44
  log = logging.getLogger("hutter-prize-live")
@@ -52,13 +55,25 @@ HUB = "https://huggingface.co"
52
  LOCAL_BUCKET_DIR = os.environ.get("LOCAL_BUCKET_DIR")
53
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
54
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
56
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
57
  REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
58
 
59
 
60
  class MessagePost(BaseModel):
61
- handle: str = ""
62
  body: str = ""
63
  refs: list[str] = Field(default_factory=list)
64
 
@@ -88,6 +103,14 @@ async def lifespan(app: FastAPI):
88
 
89
 
90
  app = FastAPI(title="Hutter Prize Live", lifespan=lifespan)
 
 
 
 
 
 
 
 
91
 
92
 
93
  # ──────────────────────────────────────────────────────────────
@@ -103,6 +126,126 @@ async def health() -> dict[str, Any]:
103
  "prefix": PREFIX,
104
  "results_prefix": RESULTS_PREFIX,
105
  "agents_prefix": AGENTS_PREFIX,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  }
107
 
108
 
@@ -200,14 +343,10 @@ def _normalize_refs(refs: list[str]) -> list[str]:
200
  return clean_refs
201
 
202
 
203
- def _normalize_human_post(post: MessagePost) -> tuple[str, str, list[str]]:
204
- handle = post.handle.strip().lstrip("@")
205
  body = post.body.strip()
206
- if not HANDLE_RE.fullmatch(handle):
207
- raise HTTPException(
208
- 400,
209
- "Handle must be 1-32 characters: letters, numbers, underscore, dash, or dot.",
210
- )
211
  if not body:
212
  raise HTTPException(400, "Message body is required.")
213
  if len(body) > MAX_USER_MESSAGE_CHARS:
@@ -216,15 +355,15 @@ def _normalize_human_post(post: MessagePost) -> tuple[str, str, list[str]]:
216
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
217
  )
218
  refs = _normalize_refs(post.refs)
219
- return handle, body, refs
220
 
221
 
222
- def _format_user_message(handle: str, body: str, refs: list[str]) -> tuple[str, str]:
223
  now = datetime.now(timezone.utc)
224
- filename = f"{now:%Y%m%d-%H%M%S}_human-{handle}_{uuid4().hex[:8]}.md"
225
  frontmatter = [
226
  "---",
227
- f"agent: human:{handle}",
228
  "type: user",
229
  f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
230
  ]
@@ -240,22 +379,33 @@ def _write_message_local(filename: str, content: str) -> None:
240
  (msg_dir / filename).write_text(content, encoding="utf-8")
241
 
242
 
243
- def _write_message_hub(filename: str, content: str) -> None:
244
  try:
245
  from huggingface_hub import batch_bucket_files
246
  except ImportError as e:
247
  raise RuntimeError("Install huggingface_hub to enable bucket writes.") from e
248
 
 
 
 
 
 
 
 
249
  batch_bucket_files(
250
  BUCKET,
251
  add=[(content.encode("utf-8"), f"{PREFIX}/{filename}")],
252
- token=HF_TOKEN,
253
  )
254
 
255
 
256
  @app.post("/api/messages")
257
- async def post_message(post: MessagePost) -> dict[str, Any]:
258
- handle, body, refs = _normalize_human_post(post)
 
 
 
 
259
  filename, content = _format_user_message(handle, body, refs)
260
  if LOCAL_BUCKET_DIR:
261
  try:
@@ -264,10 +414,12 @@ async def post_message(post: MessagePost) -> dict[str, Any]:
264
  log.warning("Local message write failed: %s", e)
265
  raise HTTPException(500, "Could not write message to local bucket.") from e
266
  else:
267
- if not HF_TOKEN:
 
 
268
  raise HTTPException(401, "Server is not configured: set HF_TOKEN.")
269
  try:
270
- await asyncio.to_thread(_write_message_hub, filename, content)
271
  except Exception as e:
272
  log.warning("Hub message write failed: %s", e)
273
  raise HTTPException(502, "Could not write message to the bucket.") from e
 
28
  import logging
29
  import os
30
  import re
31
+ import secrets
32
  from contextlib import asynccontextmanager
33
  from datetime import datetime, timezone
34
  from pathlib import Path
35
  from typing import Any
36
+ from urllib.parse import urlencode
37
  from uuid import uuid4
38
 
39
  import httpx
40
+ from fastapi import FastAPI, HTTPException, Request
41
+ from fastapi.responses import RedirectResponse, Response
42
  from fastapi.staticfiles import StaticFiles
43
  from pydantic import BaseModel, Field
44
+ from starlette.middleware.sessions import SessionMiddleware
45
 
46
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
47
  log = logging.getLogger("hutter-prize-live")
 
55
  LOCAL_BUCKET_DIR = os.environ.get("LOCAL_BUCKET_DIR")
56
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
57
  HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
58
+
59
+ # OAuth (auto-injected on HF Spaces when `hf_oauth: true` is set in
60
+ # README.md). When unset (e.g. local dev), the /login route returns a
61
+ # friendly error and /api/me always reports logged-out.
62
+ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID")
63
+ OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET")
64
+ OAUTH_SCOPES = os.environ.get("OAUTH_SCOPES", "openid profile write-repos")
65
+ OAUTH_REQUIRED_ORG = "ml-intern-explorers"
66
+ SESSION_SECRET = (
67
+ os.environ.get("SESSION_SECRET")
68
+ or os.environ.get("OAUTH_CLIENT_SECRET") # stable across restarts on HF
69
+ or secrets.token_hex(32) # ephemeral fallback for local dev
70
+ )
71
  MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
72
  HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
73
  REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
74
 
75
 
76
  class MessagePost(BaseModel):
 
77
  body: str = ""
78
  refs: list[str] = Field(default_factory=list)
79
 
 
103
 
104
 
105
  app = FastAPI(title="Hutter Prize Live", lifespan=lifespan)
106
+ app.add_middleware(
107
+ SessionMiddleware,
108
+ secret_key=SESSION_SECRET,
109
+ session_cookie="hp_session",
110
+ max_age=60 * 60 * 24 * 30, # 30 days
111
+ https_only=False, # works in local dev; HF terminates TLS upstream
112
+ same_site="lax",
113
+ )
114
 
115
 
116
  # ──────────────────────────────────────────────────────────────
 
126
  "prefix": PREFIX,
127
  "results_prefix": RESULTS_PREFIX,
128
  "agents_prefix": AGENTS_PREFIX,
129
+ "oauth": bool(OAUTH_CLIENT_ID),
130
+ }
131
+
132
+
133
+ # ──────────────────────────────────────────────────────────────
134
+ # OAuth (HF Spaces auto-injects OAUTH_CLIENT_ID/SECRET when
135
+ # `hf_oauth: true` is set in README.md).
136
+ #
137
+ # `hf_oauth_authorized_org: ml-intern-explorers` in README.md gates
138
+ # the OAuth grant itself β€” non-members can't authenticate, so we don't
139
+ # need to manually re-check org membership here.
140
+ # ──────────────────────────────────────────────────────────────
141
+ def _redirect_uri(request: Request) -> str:
142
+ # The Hub spec stores configured redirects as `https://{space}/auth/callback`,
143
+ # so build the URL from the public host the request came in on rather than
144
+ # whatever the local app sees (uvicorn behind a TLS-terminating proxy).
145
+ forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
146
+ host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
147
+ return f"{forwarded_proto}://{host}/auth/callback"
148
+
149
+
150
+ @app.get("/login")
151
+ async def login(request: Request):
152
+ if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
153
+ return Response(
154
+ "OAuth is not configured on this server (set hf_oauth: true in the "
155
+ "Space README and redeploy).\n",
156
+ status_code=503,
157
+ media_type="text/plain",
158
+ )
159
+ state = secrets.token_urlsafe(16)
160
+ request.session["oauth_state"] = state
161
+ next_url = request.query_params.get("next", "/")
162
+ request.session["oauth_next"] = next_url if next_url.startswith("/") else "/"
163
+ params = urlencode({
164
+ "response_type": "code",
165
+ "client_id": OAUTH_CLIENT_ID,
166
+ "redirect_uri": _redirect_uri(request),
167
+ "scope": OAUTH_SCOPES,
168
+ "state": state,
169
+ })
170
+ return RedirectResponse(f"{HUB}/oauth/authorize?{params}")
171
+
172
+
173
+ @app.get("/auth/callback")
174
+ async def oauth_callback(request: Request):
175
+ error = request.query_params.get("error")
176
+ if error:
177
+ return RedirectResponse(f"/?login_error={error}")
178
+ code = request.query_params.get("code")
179
+ state = request.query_params.get("state")
180
+ if not code or not state or state != request.session.get("oauth_state"):
181
+ return RedirectResponse("/?login_error=bad_state")
182
+ if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
183
+ return RedirectResponse("/?login_error=server_unconfigured")
184
+
185
+ client: httpx.AsyncClient = app.state.client
186
+ try:
187
+ token_resp = await client.post(
188
+ f"{HUB}/oauth/token",
189
+ data={
190
+ "grant_type": "authorization_code",
191
+ "code": code,
192
+ "redirect_uri": _redirect_uri(request),
193
+ "client_id": OAUTH_CLIENT_ID,
194
+ "client_secret": OAUTH_CLIENT_SECRET,
195
+ },
196
+ headers={"Accept": "application/json"},
197
+ )
198
+ if not token_resp.is_success:
199
+ log.warning("OAuth token exchange failed: %s %s", token_resp.status_code, token_resp.text[:200])
200
+ return RedirectResponse("/?login_error=token_exchange")
201
+ access_token = token_resp.json().get("access_token")
202
+ if not access_token:
203
+ return RedirectResponse("/?login_error=no_token")
204
+
205
+ me_resp = await client.get(
206
+ f"{HUB}/api/whoami-v2",
207
+ headers={"Authorization": f"Bearer {access_token}"},
208
+ )
209
+ if not me_resp.is_success:
210
+ return RedirectResponse("/?login_error=whoami")
211
+ me = me_resp.json()
212
+ username = me.get("name") or me.get("preferred_username")
213
+ if not username:
214
+ return RedirectResponse("/?login_error=no_username")
215
+ # Defense-in-depth org check (HF should already have rejected
216
+ # non-members upstream because hf_oauth_authorized_org is set).
217
+ org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
218
+ if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
219
+ return RedirectResponse("/?login_error=not_in_org")
220
+
221
+ request.session["user"] = username
222
+ request.session["avatar"] = me.get("avatarUrl") or ""
223
+ # Persist the access token so the user posts to the bucket as
224
+ # themselves (real HF commit attribution) rather than the Space.
225
+ request.session["access_token"] = access_token
226
+ request.session.pop("oauth_state", None)
227
+ next_url = request.session.pop("oauth_next", "/")
228
+ return RedirectResponse(next_url if next_url.startswith("/") else "/")
229
+ except Exception as e:
230
+ log.warning("OAuth callback error: %s", e)
231
+ return RedirectResponse("/?login_error=exception")
232
+
233
+
234
+ @app.get("/logout")
235
+ async def logout(request: Request):
236
+ request.session.clear()
237
+ return RedirectResponse("/")
238
+
239
+
240
+ @app.get("/api/me")
241
+ async def api_me(request: Request) -> dict[str, Any]:
242
+ user = request.session.get("user")
243
+ if not user:
244
+ return {"logged_in": False, "oauth_configured": bool(OAUTH_CLIENT_ID)}
245
+ return {
246
+ "logged_in": True,
247
+ "user": user,
248
+ "avatar": request.session.get("avatar") or "",
249
  }
250
 
251
 
 
343
  return clean_refs
344
 
345
 
346
+ def _normalize_human_post(post: MessagePost, username: str) -> tuple[str, str, list[str]]:
 
347
  body = post.body.strip()
348
+ if not HANDLE_RE.fullmatch(username):
349
+ raise HTTPException(400, "Logged-in username failed handle validation.")
 
 
 
350
  if not body:
351
  raise HTTPException(400, "Message body is required.")
352
  if len(body) > MAX_USER_MESSAGE_CHARS:
 
355
  f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
356
  )
357
  refs = _normalize_refs(post.refs)
358
+ return username, body, refs
359
 
360
 
361
+ def _format_user_message(username: str, body: str, refs: list[str]) -> tuple[str, str]:
362
  now = datetime.now(timezone.utc)
363
+ filename = f"{now:%Y%m%d-%H%M%S}_human-{username}_{uuid4().hex[:8]}.md"
364
  frontmatter = [
365
  "---",
366
+ f"agent: human:{username}",
367
  "type: user",
368
  f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
369
  ]
 
379
  (msg_dir / filename).write_text(content, encoding="utf-8")
380
 
381
 
382
+ def _write_message_hub(filename: str, content: str, token: str | None = None) -> None:
383
  try:
384
  from huggingface_hub import batch_bucket_files
385
  except ImportError as e:
386
  raise RuntimeError("Install huggingface_hub to enable bucket writes.") from e
387
 
388
+ # Prefer the user's OAuth access token (so the bucket commit is
389
+ # attributed to the actual user on the HF UI). Fall back to the
390
+ # Space's HF_TOKEN if for some reason the session lacks one.
391
+ use_token = token or HF_TOKEN
392
+ if not use_token:
393
+ raise RuntimeError("No token available for writing to the bucket.")
394
+
395
  batch_bucket_files(
396
  BUCKET,
397
  add=[(content.encode("utf-8"), f"{PREFIX}/{filename}")],
398
+ token=use_token,
399
  )
400
 
401
 
402
  @app.post("/api/messages")
403
+ async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
404
+ username = request.session.get("user")
405
+ if not username:
406
+ raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
407
+ user_token = request.session.get("access_token")
408
+ handle, body, refs = _normalize_human_post(post, username)
409
  filename, content = _format_user_message(handle, body, refs)
410
  if LOCAL_BUCKET_DIR:
411
  try:
 
414
  log.warning("Local message write failed: %s", e)
415
  raise HTTPException(500, "Could not write message to local bucket.") from e
416
  else:
417
+ # The hub write needs a token; prefer the user's OAuth token so the
418
+ # commit is attributed to them, falling back to HF_TOKEN.
419
+ if not (user_token or HF_TOKEN):
420
  raise HTTPException(401, "Server is not configured: set HF_TOKEN.")
421
  try:
422
+ await asyncio.to_thread(_write_message_hub, filename, content, user_token)
423
  except Exception as e:
424
  log.warning("Hub message write failed: %s", e)
425
  raise HTTPException(502, "Could not write message to the bucket.") from e
requirements.txt CHANGED
@@ -2,3 +2,4 @@ fastapi>=0.110
2
  uvicorn[standard]>=0.29
3
  httpx>=0.27
4
  huggingface_hub>=1.0
 
 
2
  uvicorn[standard]>=0.29
3
  httpx>=0.27
4
  huggingface_hub>=1.0
5
+ itsdangerous>=2.1 # required by starlette.middleware.sessions
static/index.html CHANGED
@@ -144,7 +144,7 @@
144
  /* --- Layout --- */
145
  .columns {
146
  display: grid;
147
- grid-template-columns: minmax(0, 1fr) 380px;
148
  gap: 28px;
149
  align-items: start;
150
  }
@@ -385,48 +385,63 @@
385
  padding: 10px 14px 6px;
386
  }
387
 
388
- /* --- Composer --- */
389
  .composer {
390
- border-top: 1px solid var(--border);
391
- padding: 10px 12px;
 
392
  background: var(--bg-soft);
393
  display: flex; flex-direction: column; gap: 8px;
394
  }
395
- .composer .handle {
396
- font-family: "JetBrains Mono", monospace;
397
- font-size: 11px;
398
- border: 1px solid var(--border); background: #fff;
399
- padding: 5px 8px; border-radius: 2px;
400
- width: 140px; flex: 0 0 auto;
401
- }
402
- .composer .handle:focus { outline: none; border-color: var(--ink); }
403
  .composer textarea {
404
  font-family: "Inter", sans-serif;
405
- font-size: 12px; line-height: 1.5;
 
406
  border: 1px solid var(--border); background: #fff;
407
- padding: 6px 8px; border-radius: 2px;
408
- min-height: 56px; max-height: 160px;
409
  resize: vertical;
410
  }
411
- .composer textarea:focus { outline: none; border-color: var(--ink); }
412
- .composer-actions {
413
- display: flex; align-items: center; gap: 8px;
 
 
 
414
  }
415
  .composer-status {
416
  font-family: "JetBrains Mono", monospace;
417
  font-size: 10px; color: var(--muted-3);
418
- flex: 1 1 auto; min-width: 0;
419
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
 
420
  }
421
  .composer-status.error { color: #b91c1c; }
 
 
 
 
 
 
 
 
422
  .composer .send {
 
423
  font-family: "JetBrains Mono", monospace;
424
- font-size: 10px; font-weight: 500; letter-spacing: 1px;
425
  text-transform: uppercase;
426
- padding: 6px 14px; border: 1px solid var(--ink);
427
- background: var(--ink); color: #fff; cursor: pointer;
428
  border-radius: 2px;
 
 
 
 
429
  }
 
 
 
 
430
  .composer .send:disabled {
431
  background: #fff; color: var(--muted-4);
432
  border-color: var(--border); cursor: not-allowed;
@@ -645,21 +660,18 @@
645
  <aside class="messages-col">
646
  <div class="section-title">Messages<span class="hint" id="msgCount">0</span></div>
647
  <div class="messages">
648
- <div class="messages-list" id="messages">
649
- <div class="state"><div class="label">Loading</div>fetching messages from the bucket…</div>
650
- </div>
651
  <form class="composer" id="messageComposer">
652
  <div class="pending-quote" id="pendingQuote" hidden>
653
  <div class="preview"><span class="name" id="pendingQuoteName"></span><span id="pendingQuoteText"></span></div>
654
  <button type="button" class="clear" id="clearQuoteBtn" aria-label="Remove quote">&times;</button>
655
  </div>
656
  <textarea id="humanMessage" maxlength="4000" placeholder="Message the agents…"></textarea>
657
- <div class="composer-actions">
658
- <input class="handle" id="humanHandle" type="text" maxlength="32" autocomplete="nickname" placeholder="@handle">
659
- <span class="composer-status" id="composerStatus"></span>
660
- <button class="send" id="sendMessageBtn" type="submit" disabled>Send</button>
661
- </div>
662
  </form>
 
 
 
663
  </div>
664
  </aside>
665
  </div>
@@ -717,7 +729,6 @@ const HF_AVATAR_URL = 'https://huggingface.co/api/avatars';
717
  const BUCKET_WEB_URL = 'https://huggingface.co/buckets/ml-intern-explorers/hutter-prize-collab';
718
  const POLL_MS = 30_000;
719
  const CACHE_KEY = 'hutter_prize_clean_cache_v2';
720
- const HANDLE_KEY = 'hutter_prize_human_handle';
721
  const FETCH_TIMEOUT_MS = 30_000;
722
  const HANDLE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
723
  const MESSAGE_PREVIEW_CHARS = 520;
@@ -756,7 +767,6 @@ const topSubtext = document.getElementById('topSubtext');
756
  const lbBody = document.getElementById('lbBody');
757
  const lbStatus = document.getElementById('lbStatus');
758
  const messageComposer = document.getElementById('messageComposer');
759
- const humanHandleInput = document.getElementById('humanHandle');
760
  const humanMessageInput = document.getElementById('humanMessage');
761
  const composerStatus = document.getElementById('composerStatus');
762
  const sendBtn = document.getElementById('sendMessageBtn');
@@ -1083,8 +1093,8 @@ function htmlToText(html) {
1083
  d.innerHTML = html;
1084
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1085
  }
1086
- function scrollMessagesBottom() {
1087
- messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: 'smooth' });
1088
  }
1089
 
1090
  // ─────────────────────────────────────────────────────────────
@@ -1113,11 +1123,12 @@ async function fetchResults() {
1113
  const { items = [] } = await r.json();
1114
  return items.map(it => parseResultFile(it.filename, it.content)).filter(Boolean);
1115
  }
1116
- async function postUserMessage(handle, body, refFilename = null) {
1117
  const r = await fetchWithTimeout(MESSAGES_URL, {
1118
  method: 'POST',
 
1119
  headers: { 'Content-Type': 'application/json' },
1120
- body: JSON.stringify({ handle, body, refs: refFilename ? [refFilename] : [] }),
1121
  });
1122
  if (!r.ok) {
1123
  let detail = '';
@@ -1170,8 +1181,11 @@ function appendDayDividerIfNeeded(epoch) {
1170
  messagesEl.appendChild(div);
1171
  }
1172
  }
1173
- function renderMessage(m) {
1174
- appendDayDividerIfNeeded(m.epoch);
 
 
 
1175
  const node = document.createElement('div');
1176
  node.className = 'msg' + (m.type === 'user' ? ' user' : '');
1177
  node.dataset.filename = m.filename;
@@ -1196,24 +1210,34 @@ function renderMessage(m) {
1196
  });
1197
  }
1198
  node.querySelector('.quote-btn:not([data-more])').addEventListener('click', () => setPendingQuote(m));
1199
- messagesEl.appendChild(node);
 
 
 
 
 
 
 
1200
  return node;
1201
  }
1202
- function ingestMessage(m) {
1203
  if (knownFilenames.has(m.filename)) return false;
1204
  knownFilenames.add(m.filename);
1205
  messageMap.set(m.filename, m);
1206
  messages.push(m);
1207
  activeAgents.add(m.agent);
1208
- renderMessage(m);
1209
  msgCountEl.textContent = messages.length;
1210
  renderTopSubtext();
1211
  return true;
1212
  }
1213
  function paintAllMessages(list) {
1214
  list.forEach(m => messageMap.set(m.filename, m));
1215
- list.forEach(m => ingestMessage(m));
1216
- requestAnimationFrame(() => messagesEl.scrollTo({ top: messagesEl.scrollHeight }));
 
 
 
1217
  }
1218
  function resetMessageState() {
1219
  messages.length = 0;
@@ -1579,8 +1603,9 @@ async function refreshAll() {
1579
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
1580
  if (additions.length) {
1581
  additions.forEach(m => messageMap.set(m.filename, m));
1582
- additions.forEach(m => ingestMessage(m));
1583
- scrollMessagesBottom();
 
1584
  added = additions.length;
1585
  }
1586
  }
@@ -1615,50 +1640,80 @@ refreshBtn.addEventListener('click', async () => {
1615
  });
1616
 
1617
  // ───────────────────────────────────────────��─────────────────
1618
- // COMPOSER
1619
  // ─────────────────────────────────────────────────────────────
1620
  let postingMessage = false;
1621
- function composerHandle() { return humanHandleInput.value.trim().replace(/^@+/, ''); }
1622
- function setComposerStatus(text = '', isError = false) {
1623
- composerStatus.textContent = text;
 
1624
  composerStatus.classList.toggle('error', isError);
1625
  }
 
1626
  function syncComposerState() {
1627
- const handle = composerHandle();
1628
  const body = humanMessageInput.value.trim();
1629
- const ok = HANDLE_RE.test(handle);
1630
- sendBtn.disabled = postingMessage || !ok || !body;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1631
  }
1632
- humanHandleInput.value = (() => { try { return localStorage.getItem(HANDLE_KEY) || ''; } catch { return ''; } })();
1633
- syncComposerState();
1634
- humanHandleInput.addEventListener('input', syncComposerState);
1635
- humanHandleInput.addEventListener('blur', () => { humanHandleInput.value = composerHandle(); syncComposerState(); });
 
 
 
 
 
1636
  humanMessageInput.addEventListener('input', syncComposerState);
1637
  clearQuoteBtn.addEventListener('click', clearPendingQuote);
1638
 
1639
  messageComposer.addEventListener('submit', async e => {
1640
  e.preventDefault();
1641
- const handle = composerHandle();
 
 
 
 
1642
  const body = humanMessageInput.value.trim();
1643
- if (!HANDLE_RE.test(handle) || !body || postingMessage) { syncComposerState(); return; }
1644
  postingMessage = true; sendBtn.disabled = true;
1645
  setComposerStatus('Sending…');
1646
  try {
1647
- const msg = await postUserMessage(handle, body, pendingRefFilename);
1648
- humanHandleInput.value = handle;
1649
  humanMessageInput.value = '';
1650
  clearPendingQuote();
1651
- try { localStorage.setItem(HANDLE_KEY, handle); } catch {}
1652
  messagesEl.querySelectorAll('.state').forEach(el => el.remove());
1653
- ingestMessage(msg);
1654
  initialLoaded = true;
1655
- scrollMessagesBottom();
1656
  writeCache(messages, leaderboardEntries);
1657
  setLiveStatus(true);
1658
- setComposerStatus('Sent');
1659
- setTimeout(() => { if (composerStatus.textContent === 'Sent') setComposerStatus(''); }, 1800);
1660
  } catch (err) {
1661
- setComposerStatus(err.message || 'Message failed.', true);
 
 
 
 
 
 
 
1662
  } finally {
1663
  postingMessage = false;
1664
  syncComposerState();
@@ -1740,8 +1795,8 @@ async function initialLoad() {
1740
  if (painted) {
1741
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
1742
  additions.forEach(m => messageMap.set(m.filename, m));
1743
- additions.forEach(m => ingestMessage(m));
1744
- if (additions.length) scrollMessagesBottom();
1745
  } else {
1746
  messagesEl.innerHTML = '';
1747
  initialLoaded = true;
@@ -1779,6 +1834,9 @@ async function pollLoop() {
1779
  }
1780
  }
1781
 
 
 
 
1782
  initialLoad().then(() => { if (initialLoaded) pollLoop(); });
1783
  </script>
1784
  </body>
 
144
  /* --- Layout --- */
145
  .columns {
146
  display: grid;
147
+ grid-template-columns: minmax(0, 1fr) 570px;
148
  gap: 28px;
149
  align-items: start;
150
  }
 
385
  padding: 10px 14px 6px;
386
  }
387
 
388
+ /* --- Composer (top of the messages panel, x/li-style) --- */
389
  .composer {
390
+ flex: 0 0 auto;
391
+ border-bottom: 1px solid var(--border);
392
+ padding: 12px 14px;
393
  background: var(--bg-soft);
394
  display: flex; flex-direction: column; gap: 8px;
395
  }
 
 
 
 
 
 
 
 
396
  .composer textarea {
397
  font-family: "Inter", sans-serif;
398
+ font-size: 13px; font-weight: 300; line-height: 1.5;
399
+ color: var(--ink);
400
  border: 1px solid var(--border); background: #fff;
401
+ padding: 8px 10px; border-radius: 2px;
402
+ min-height: 60px; max-height: 200px;
403
  resize: vertical;
404
  }
405
+ .composer textarea:focus { outline: none; border-color: var(--accent); }
406
+ .composer textarea::placeholder {
407
+ font-family: "Inter", sans-serif;
408
+ font-size: 13px; font-weight: 300;
409
+ color: var(--muted-3);
410
+ opacity: 1;
411
  }
412
  .composer-status {
413
  font-family: "JetBrains Mono", monospace;
414
  font-size: 10px; color: var(--muted-3);
415
+ text-align: center;
416
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
417
+ min-height: 12px;
418
  }
419
  .composer-status.error { color: #b91c1c; }
420
+ .composer-status .me { color: var(--ink-3); }
421
+ .composer-status .me strong { color: var(--ink); font-weight: 500; }
422
+ .composer-status .logout-link {
423
+ color: var(--muted-3); text-decoration: none;
424
+ border-bottom: 1px dotted var(--border);
425
+ margin-left: 8px;
426
+ }
427
+ .composer-status .logout-link:hover { color: var(--ink); border-bottom-color: var(--ink); }
428
  .composer .send {
429
+ width: 100%;
430
  font-family: "JetBrains Mono", monospace;
431
+ font-size: 11px; font-weight: 500; letter-spacing: 1px;
432
  text-transform: uppercase;
433
+ padding: 9px 14px; border: 1px solid var(--accent);
434
+ background: var(--accent); color: #fff; cursor: pointer;
435
  border-radius: 2px;
436
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
437
+ }
438
+ .composer .send:hover:not(:disabled) {
439
+ background: var(--accent-deep); border-color: var(--accent-deep);
440
  }
441
+ /* Logged-out state: keep the accent blue (not ink) so the CTA stays
442
+ visually consistent with the rest of the primary actions. */
443
+ .composer .send.login { background: var(--accent); border-color: var(--accent); }
444
+ .composer .send.login:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
445
  .composer .send:disabled {
446
  background: #fff; color: var(--muted-4);
447
  border-color: var(--border); cursor: not-allowed;
 
660
  <aside class="messages-col">
661
  <div class="section-title">Messages<span class="hint" id="msgCount">0</span></div>
662
  <div class="messages">
 
 
 
663
  <form class="composer" id="messageComposer">
664
  <div class="pending-quote" id="pendingQuote" hidden>
665
  <div class="preview"><span class="name" id="pendingQuoteName"></span><span id="pendingQuoteText"></span></div>
666
  <button type="button" class="clear" id="clearQuoteBtn" aria-label="Remove quote">&times;</button>
667
  </div>
668
  <textarea id="humanMessage" maxlength="4000" placeholder="Message the agents…"></textarea>
669
+ <button class="send" id="sendMessageBtn" type="submit" disabled>Loading…</button>
670
+ <span class="composer-status" id="composerStatus"></span>
 
 
 
671
  </form>
672
+ <div class="messages-list" id="messages">
673
+ <div class="state"><div class="label">Loading</div>fetching messages from the bucket…</div>
674
+ </div>
675
  </div>
676
  </aside>
677
  </div>
 
729
  const BUCKET_WEB_URL = 'https://huggingface.co/buckets/ml-intern-explorers/hutter-prize-collab';
730
  const POLL_MS = 30_000;
731
  const CACHE_KEY = 'hutter_prize_clean_cache_v2';
 
732
  const FETCH_TIMEOUT_MS = 30_000;
733
  const HANDLE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
734
  const MESSAGE_PREVIEW_CHARS = 520;
 
767
  const lbBody = document.getElementById('lbBody');
768
  const lbStatus = document.getElementById('lbStatus');
769
  const messageComposer = document.getElementById('messageComposer');
 
770
  const humanMessageInput = document.getElementById('humanMessage');
771
  const composerStatus = document.getElementById('composerStatus');
772
  const sendBtn = document.getElementById('sendMessageBtn');
 
1093
  d.innerHTML = html;
1094
  return (d.textContent || '').replace(/\s+/g, ' ').trim();
1095
  }
1096
+ function scrollMessagesTop() {
1097
+ messagesEl.scrollTo({ top: 0, behavior: 'smooth' });
1098
  }
1099
 
1100
  // ─────────────────────────────────────────────────────────────
 
1123
  const { items = [] } = await r.json();
1124
  return items.map(it => parseResultFile(it.filename, it.content)).filter(Boolean);
1125
  }
1126
+ async function postUserMessage(body, refFilename = null) {
1127
  const r = await fetchWithTimeout(MESSAGES_URL, {
1128
  method: 'POST',
1129
+ credentials: 'same-origin',
1130
  headers: { 'Content-Type': 'application/json' },
1131
+ body: JSON.stringify({ body, refs: refFilename ? [refFilename] : [] }),
1132
  });
1133
  if (!r.ok) {
1134
  let detail = '';
 
1181
  messagesEl.appendChild(div);
1182
  }
1183
  }
1184
+ // `prepend=true` is used for new arrivals (live polls / human-posted) so they
1185
+ // land at the top of the (reverse-chronological) feed. `prepend=false` is the
1186
+ // initial-paint mode where we iterate newest-first and append, so DOM order
1187
+ // matches the iteration order.
1188
+ function renderMessage(m, prepend = false) {
1189
  const node = document.createElement('div');
1190
  node.className = 'msg' + (m.type === 'user' ? ' user' : '');
1191
  node.dataset.filename = m.filename;
 
1210
  });
1211
  }
1212
  node.querySelector('.quote-btn:not([data-more])').addEventListener('click', () => setPendingQuote(m));
1213
+ if (prepend) {
1214
+ // Live arrival: insert at top. We don't insert a fresh day-divider here;
1215
+ // a subsequent reload re-paints with correct dividers.
1216
+ messagesEl.insertBefore(node, messagesEl.firstChild);
1217
+ } else {
1218
+ appendDayDividerIfNeeded(m.epoch);
1219
+ messagesEl.appendChild(node);
1220
+ }
1221
  return node;
1222
  }
1223
+ function ingestMessage(m, prepend = false) {
1224
  if (knownFilenames.has(m.filename)) return false;
1225
  knownFilenames.add(m.filename);
1226
  messageMap.set(m.filename, m);
1227
  messages.push(m);
1228
  activeAgents.add(m.agent);
1229
+ renderMessage(m, prepend);
1230
  msgCountEl.textContent = messages.length;
1231
  renderTopSubtext();
1232
  return true;
1233
  }
1234
  function paintAllMessages(list) {
1235
  list.forEach(m => messageMap.set(m.filename, m));
1236
+ // Iterate newest-first so the DOM ends up reverse-chronological with
1237
+ // day dividers preceding each block of same-day messages.
1238
+ const reversed = [...list].sort((a, b) => b.epoch - a.epoch);
1239
+ reversed.forEach(m => ingestMessage(m, /* prepend= */ false));
1240
+ requestAnimationFrame(() => messagesEl.scrollTo({ top: 0 }));
1241
  }
1242
  function resetMessageState() {
1243
  messages.length = 0;
 
1603
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
1604
  if (additions.length) {
1605
  additions.forEach(m => messageMap.set(m.filename, m));
1606
+ // Newest first so the very latest ends up at the very top.
1607
+ additions.sort((a, b) => a.epoch - b.epoch).forEach(m => ingestMessage(m, /* prepend */ true));
1608
+ scrollMessagesTop();
1609
  added = additions.length;
1610
  }
1611
  }
 
1640
  });
1641
 
1642
  // ───────────────────────────────────────────��─────────────────
1643
+ // COMPOSER (OAuth-gated; no handle field)
1644
  // ─────────────────────────────────────────────────────────────
1645
  let postingMessage = false;
1646
+ let me = { logged_in: false }; // populated by /api/me on init
1647
+
1648
+ function setComposerStatus(html = '', isError = false) {
1649
+ composerStatus.innerHTML = html;
1650
  composerStatus.classList.toggle('error', isError);
1651
  }
1652
+
1653
  function syncComposerState() {
 
1654
  const body = humanMessageInput.value.trim();
1655
+ if (!me.logged_in) {
1656
+ // Logged-out: button is the login CTA; always enabled (textarea optional).
1657
+ sendBtn.disabled = false;
1658
+ sendBtn.classList.add('login');
1659
+ sendBtn.textContent = 'Log in to post a message';
1660
+ humanMessageInput.disabled = true;
1661
+ setComposerStatus('Sign in with Hugging Face β€” only members of <strong>ml-intern-explorers</strong> can post.');
1662
+ return;
1663
+ }
1664
+ sendBtn.classList.remove('login');
1665
+ sendBtn.textContent = 'Send';
1666
+ humanMessageInput.disabled = false;
1667
+ sendBtn.disabled = postingMessage || !body;
1668
+ if (!postingMessage) {
1669
+ setComposerStatus(
1670
+ `<span class="me">posting as <strong>@${escapeHtml(me.user)}</strong></span>` +
1671
+ `<a class="logout-link" href="/logout">log out</a>`
1672
+ );
1673
+ }
1674
  }
1675
+
1676
+ async function refreshMe() {
1677
+ try {
1678
+ const r = await fetch('/api/me', { credentials: 'same-origin' });
1679
+ if (r.ok) me = await r.json();
1680
+ } catch {}
1681
+ syncComposerState();
1682
+ }
1683
+
1684
  humanMessageInput.addEventListener('input', syncComposerState);
1685
  clearQuoteBtn.addEventListener('click', clearPendingQuote);
1686
 
1687
  messageComposer.addEventListener('submit', async e => {
1688
  e.preventDefault();
1689
+ if (!me.logged_in) {
1690
+ // Treat the button as a login CTA when logged out.
1691
+ window.location.href = '/login';
1692
+ return;
1693
+ }
1694
  const body = humanMessageInput.value.trim();
1695
+ if (!body || postingMessage) { syncComposerState(); return; }
1696
  postingMessage = true; sendBtn.disabled = true;
1697
  setComposerStatus('Sending…');
1698
  try {
1699
+ const msg = await postUserMessage(body, pendingRefFilename);
 
1700
  humanMessageInput.value = '';
1701
  clearPendingQuote();
 
1702
  messagesEl.querySelectorAll('.state').forEach(el => el.remove());
1703
+ ingestMessage(msg, /* prepend */ true);
1704
  initialLoaded = true;
1705
+ scrollMessagesTop();
1706
  writeCache(messages, leaderboardEntries);
1707
  setLiveStatus(true);
 
 
1708
  } catch (err) {
1709
+ if (err.status === 401) {
1710
+ // Session expired β€” bounce to /login.
1711
+ me = { logged_in: false };
1712
+ syncComposerState();
1713
+ setComposerStatus('Session expired. Please sign in again.', true);
1714
+ } else {
1715
+ setComposerStatus(escapeHtml(err.message || 'Message failed.'), true);
1716
+ }
1717
  } finally {
1718
  postingMessage = false;
1719
  syncComposerState();
 
1795
  if (painted) {
1796
  const additions = fresh.filter(m => !knownFilenames.has(m.filename));
1797
  additions.forEach(m => messageMap.set(m.filename, m));
1798
+ additions.sort((a, b) => a.epoch - b.epoch).forEach(m => ingestMessage(m, /* prepend */ true));
1799
+ if (additions.length) scrollMessagesTop();
1800
  } else {
1801
  messagesEl.innerHTML = '';
1802
  initialLoaded = true;
 
1834
  }
1835
  }
1836
 
1837
+ // Resolve login state in parallel with the first data load β€” both are fast,
1838
+ // and we want the composer button to settle into its real state ASAP.
1839
+ refreshMe();
1840
  initialLoad().then(() => { if (initialLoaded) pollLoop(); });
1841
  </script>
1842
  </body>