OAuth login (hf_oauth + write-repos), reversed feed, composer-at-top, wider chat (570px)
Browse files- README.md +5 -0
- app.py +172 -20
- requirements.txt +1 -0
- 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(
|
| 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
|
| 220 |
|
| 221 |
|
| 222 |
-
def _format_user_message(
|
| 223 |
now = datetime.now(timezone.utc)
|
| 224 |
-
filename = f"{now:%Y%m%d-%H%M%S}_human-{
|
| 225 |
frontmatter = [
|
| 226 |
"---",
|
| 227 |
-
f"agent: human:{
|
| 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=
|
| 253 |
)
|
| 254 |
|
| 255 |
|
| 256 |
@app.post("/api/messages")
|
| 257 |
-
async def post_message(post: MessagePost) -> dict[str, Any]:
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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)
|
| 148 |
gap: 28px;
|
| 149 |
align-items: start;
|
| 150 |
}
|
|
@@ -385,48 +385,63 @@
|
|
| 385 |
padding: 10px 14px 6px;
|
| 386 |
}
|
| 387 |
|
| 388 |
-
/* --- Composer --- */
|
| 389 |
.composer {
|
| 390 |
-
|
| 391 |
-
|
|
|
|
| 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:
|
|
|
|
| 406 |
border: 1px solid var(--border); background: #fff;
|
| 407 |
-
padding:
|
| 408 |
-
min-height:
|
| 409 |
resize: vertical;
|
| 410 |
}
|
| 411 |
-
.composer textarea:focus { outline: none; border-color: var(--
|
| 412 |
-
.composer
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
| 414 |
}
|
| 415 |
.composer-status {
|
| 416 |
font-family: "JetBrains Mono", monospace;
|
| 417 |
font-size: 10px; color: var(--muted-3);
|
| 418 |
-
|
| 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:
|
| 425 |
text-transform: uppercase;
|
| 426 |
-
padding:
|
| 427 |
-
background: var(--
|
| 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">×</button>
|
| 655 |
</div>
|
| 656 |
<textarea id="humanMessage" maxlength="4000" placeholder="Message the agentsβ¦"></textarea>
|
| 657 |
-
<
|
| 658 |
-
|
| 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
|
| 1087 |
-
messagesEl.scrollTo({ top:
|
| 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(
|
| 1117 |
const r = await fetchWithTimeout(MESSAGES_URL, {
|
| 1118 |
method: 'POST',
|
|
|
|
| 1119 |
headers: { 'Content-Type': 'application/json' },
|
| 1120 |
-
body: JSON.stringify({
|
| 1121 |
});
|
| 1122 |
if (!r.ok) {
|
| 1123 |
let detail = '';
|
|
@@ -1170,8 +1181,11 @@ function appendDayDividerIfNeeded(epoch) {
|
|
| 1170 |
messagesEl.appendChild(div);
|
| 1171 |
}
|
| 1172 |
}
|
| 1173 |
-
|
| 1174 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1216 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1583 |
-
|
|
|
|
| 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 |
-
|
| 1622 |
-
|
| 1623 |
-
|
|
|
|
| 1624 |
composerStatus.classList.toggle('error', isError);
|
| 1625 |
}
|
|
|
|
| 1626 |
function syncComposerState() {
|
| 1627 |
-
const handle = composerHandle();
|
| 1628 |
const body = humanMessageInput.value.trim();
|
| 1629 |
-
|
| 1630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1631 |
}
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1636 |
humanMessageInput.addEventListener('input', syncComposerState);
|
| 1637 |
clearQuoteBtn.addEventListener('click', clearPendingQuote);
|
| 1638 |
|
| 1639 |
messageComposer.addEventListener('submit', async e => {
|
| 1640 |
e.preventDefault();
|
| 1641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
const body = humanMessageInput.value.trim();
|
| 1643 |
-
if (!
|
| 1644 |
postingMessage = true; sendBtn.disabled = true;
|
| 1645 |
setComposerStatus('Sendingβ¦');
|
| 1646 |
try {
|
| 1647 |
-
const msg = await postUserMessage(
|
| 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 |
-
|
| 1656 |
writeCache(messages, leaderboardEntries);
|
| 1657 |
setLiveStatus(true);
|
| 1658 |
-
setComposerStatus('Sent');
|
| 1659 |
-
setTimeout(() => { if (composerStatus.textContent === 'Sent') setComposerStatus(''); }, 1800);
|
| 1660 |
} catch (err) {
|
| 1661 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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">×</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>
|