Upload folder using huggingface_hub
Browse files- app.py +85 -14
- static/index.html +59 -2
app.py
CHANGED
|
@@ -29,6 +29,7 @@ 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
|
|
@@ -83,15 +84,32 @@ async def lifespan(app: FastAPI):
|
|
| 83 |
headers: dict[str, str] = {}
|
| 84 |
if HF_TOKEN:
|
| 85 |
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
|
|
|
|
|
|
| 86 |
app.state.client = httpx.AsyncClient(
|
| 87 |
headers=headers,
|
| 88 |
timeout=httpx.Timeout(HUB_FETCH_TIMEOUT),
|
| 89 |
follow_redirects=True, # Hub redirects /resolve/ → cas-bridge.xethub
|
|
|
|
| 90 |
)
|
| 91 |
if LOCAL_BUCKET_DIR:
|
| 92 |
log.info("Local mode — reading from %s", LOCAL_BUCKET_DIR)
|
| 93 |
elif HF_TOKEN:
|
| 94 |
log.info("Hub mode — fetching from %s with HF_TOKEN", HUB)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
else:
|
| 96 |
log.warning(
|
| 97 |
"Neither LOCAL_BUCKET_DIR nor HF_TOKEN is set. /api/* will 401."
|
|
@@ -177,14 +195,28 @@ async def login(request: Request):
|
|
| 177 |
|
| 178 |
@app.get("/auth/callback")
|
| 179 |
async def oauth_callback(request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
error = request.query_params.get("error")
|
| 181 |
if error:
|
|
|
|
| 182 |
return RedirectResponse(f"/?login_error={error}")
|
| 183 |
code = request.query_params.get("code")
|
| 184 |
state = request.query_params.get("state")
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
return RedirectResponse("/?login_error=bad_state")
|
| 187 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
|
|
|
| 188 |
return RedirectResponse("/?login_error=server_unconfigured")
|
| 189 |
|
| 190 |
# Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
|
|
@@ -204,10 +236,11 @@ async def oauth_callback(request: Request):
|
|
| 204 |
headers={"Accept": "application/json"},
|
| 205 |
)
|
| 206 |
if not token_resp.is_success:
|
| 207 |
-
log.warning("
|
| 208 |
return RedirectResponse("/?login_error=token_exchange")
|
| 209 |
access_token = token_resp.json().get("access_token")
|
| 210 |
if not access_token:
|
|
|
|
| 211 |
return RedirectResponse("/?login_error=no_token")
|
| 212 |
|
| 213 |
me_resp = await oauth_client.get(
|
|
@@ -215,15 +248,18 @@ async def oauth_callback(request: Request):
|
|
| 215 |
headers={"Authorization": f"Bearer {access_token}"},
|
| 216 |
)
|
| 217 |
if not me_resp.is_success:
|
|
|
|
| 218 |
return RedirectResponse("/?login_error=whoami")
|
| 219 |
me = me_resp.json()
|
| 220 |
username = me.get("name") or me.get("preferred_username")
|
| 221 |
if not username:
|
|
|
|
| 222 |
return RedirectResponse("/?login_error=no_username")
|
| 223 |
# Defense-in-depth org check (HF should already have rejected
|
| 224 |
# non-members upstream because hf_oauth_authorized_org is set).
|
| 225 |
org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
|
| 226 |
if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
|
|
|
|
| 227 |
return RedirectResponse("/?login_error=not_in_org")
|
| 228 |
|
| 229 |
request.session["user"] = username
|
|
@@ -233,9 +269,10 @@ async def oauth_callback(request: Request):
|
|
| 233 |
request.session["access_token"] = access_token
|
| 234 |
request.session.pop("oauth_state", None)
|
| 235 |
next_url = request.session.pop("oauth_next", "/")
|
|
|
|
| 236 |
return RedirectResponse(next_url if next_url.startswith("/") else "/")
|
| 237 |
except Exception as e:
|
| 238 |
-
log.warning("
|
| 239 |
return RedirectResponse("/?login_error=exception")
|
| 240 |
|
| 241 |
|
|
@@ -312,32 +349,63 @@ async def _list_md_hub(prefix: str) -> list[dict[str, str]]:
|
|
| 312 |
return [r for r in results if r is not None]
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
# ──────────────────────────────────────────────────────────────
|
| 316 |
# /api/messages and /api/results
|
| 317 |
# ──────────────────────────────────────────────────────────────
|
| 318 |
@app.get("/api/messages")
|
| 319 |
async def messages() -> dict[str, Any]:
|
| 320 |
-
items =
|
| 321 |
return {"items": items, "count": len(items)}
|
| 322 |
|
| 323 |
|
| 324 |
@app.get("/api/results")
|
| 325 |
async def results() -> dict[str, Any]:
|
| 326 |
-
items = (
|
| 327 |
-
_list_md_local(RESULTS_PREFIX)
|
| 328 |
-
if LOCAL_BUCKET_DIR
|
| 329 |
-
else await _list_md_hub(RESULTS_PREFIX)
|
| 330 |
-
)
|
| 331 |
return {"items": items, "count": len(items)}
|
| 332 |
|
| 333 |
|
| 334 |
@app.get("/api/agents")
|
| 335 |
async def agents() -> dict[str, Any]:
|
| 336 |
-
items = (
|
| 337 |
-
_list_md_local(AGENTS_PREFIX)
|
| 338 |
-
if LOCAL_BUCKET_DIR
|
| 339 |
-
else await _list_md_hub(AGENTS_PREFIX)
|
| 340 |
-
)
|
| 341 |
return {"items": items, "count": len(items)}
|
| 342 |
|
| 343 |
|
|
@@ -431,6 +499,9 @@ async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
|
|
| 431 |
except Exception as e:
|
| 432 |
log.warning("Hub message write failed: %s", e)
|
| 433 |
raise HTTPException(502, "Could not write message to the bucket.") from e
|
|
|
|
|
|
|
|
|
|
| 434 |
return {"item": {"filename": filename, "content": content}}
|
| 435 |
|
| 436 |
|
|
|
|
| 29 |
import os
|
| 30 |
import re
|
| 31 |
import secrets
|
| 32 |
+
import time
|
| 33 |
from contextlib import asynccontextmanager
|
| 34 |
from datetime import datetime, timezone
|
| 35 |
from pathlib import Path
|
|
|
|
| 84 |
headers: dict[str, str] = {}
|
| 85 |
if HF_TOKEN:
|
| 86 |
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 87 |
+
# Connection pool: ~100+ files fan-out per /api/messages call. Default
|
| 88 |
+
# max_connections=100 is borderline; bump it so we don't get queueing.
|
| 89 |
app.state.client = httpx.AsyncClient(
|
| 90 |
headers=headers,
|
| 91 |
timeout=httpx.Timeout(HUB_FETCH_TIMEOUT),
|
| 92 |
follow_redirects=True, # Hub redirects /resolve/ → cas-bridge.xethub
|
| 93 |
+
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
|
| 94 |
)
|
| 95 |
if LOCAL_BUCKET_DIR:
|
| 96 |
log.info("Local mode — reading from %s", LOCAL_BUCKET_DIR)
|
| 97 |
elif HF_TOKEN:
|
| 98 |
log.info("Hub mode — fetching from %s with HF_TOKEN", HUB)
|
| 99 |
+
# Warm the listing cache in the background so the first user request
|
| 100 |
+
# doesn't have to do the cold-cache fan-out (was ~10s blank page).
|
| 101 |
+
async def _warm_cache():
|
| 102 |
+
try:
|
| 103 |
+
await asyncio.gather(
|
| 104 |
+
_cached_list_md(PREFIX),
|
| 105 |
+
_cached_list_md(RESULTS_PREFIX),
|
| 106 |
+
_cached_list_md(AGENTS_PREFIX),
|
| 107 |
+
return_exceptions=True,
|
| 108 |
+
)
|
| 109 |
+
log.info("Cache warm-up complete.")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
log.warning("Cache warm-up failed: %s", e)
|
| 112 |
+
asyncio.create_task(_warm_cache())
|
| 113 |
else:
|
| 114 |
log.warning(
|
| 115 |
"Neither LOCAL_BUCKET_DIR nor HF_TOKEN is set. /api/* will 401."
|
|
|
|
| 195 |
|
| 196 |
@app.get("/auth/callback")
|
| 197 |
async def oauth_callback(request: Request):
|
| 198 |
+
# rid is logged on every branch so we can correlate one user's full flow
|
| 199 |
+
# in the Space logs without exposing PII. Surfaced back via header for
|
| 200 |
+
# browser-side correlation.
|
| 201 |
+
rid = secrets.token_hex(4)
|
| 202 |
error = request.query_params.get("error")
|
| 203 |
if error:
|
| 204 |
+
log.warning("[oauth %s] provider error=%s desc=%s", rid, error, request.query_params.get("error_description", "")[:200])
|
| 205 |
return RedirectResponse(f"/?login_error={error}")
|
| 206 |
code = request.query_params.get("code")
|
| 207 |
state = request.query_params.get("state")
|
| 208 |
+
session_state = request.session.get("oauth_state")
|
| 209 |
+
if not code or not state or state != session_state:
|
| 210 |
+
# The single most common failure mode in iframe deployments: the
|
| 211 |
+
# session cookie set by /login didn't make it back to /auth/callback,
|
| 212 |
+
# so the saved state is missing. Log enough to tell which it is.
|
| 213 |
+
log.warning(
|
| 214 |
+
"[oauth %s] bad_state code=%s state_param=%s session_state=%s cookies_present=%s",
|
| 215 |
+
rid, bool(code), bool(state), bool(session_state), bool(request.cookies),
|
| 216 |
+
)
|
| 217 |
return RedirectResponse("/?login_error=bad_state")
|
| 218 |
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
| 219 |
+
log.warning("[oauth %s] server_unconfigured", rid)
|
| 220 |
return RedirectResponse("/?login_error=server_unconfigured")
|
| 221 |
|
| 222 |
# Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
|
|
|
|
| 236 |
headers={"Accept": "application/json"},
|
| 237 |
)
|
| 238 |
if not token_resp.is_success:
|
| 239 |
+
log.warning("[oauth %s] token_exchange status=%s body=%s", rid, token_resp.status_code, token_resp.text[:300])
|
| 240 |
return RedirectResponse("/?login_error=token_exchange")
|
| 241 |
access_token = token_resp.json().get("access_token")
|
| 242 |
if not access_token:
|
| 243 |
+
log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
|
| 244 |
return RedirectResponse("/?login_error=no_token")
|
| 245 |
|
| 246 |
me_resp = await oauth_client.get(
|
|
|
|
| 248 |
headers={"Authorization": f"Bearer {access_token}"},
|
| 249 |
)
|
| 250 |
if not me_resp.is_success:
|
| 251 |
+
log.warning("[oauth %s] whoami status=%s body=%s", rid, me_resp.status_code, me_resp.text[:200])
|
| 252 |
return RedirectResponse("/?login_error=whoami")
|
| 253 |
me = me_resp.json()
|
| 254 |
username = me.get("name") or me.get("preferred_username")
|
| 255 |
if not username:
|
| 256 |
+
log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
|
| 257 |
return RedirectResponse("/?login_error=no_username")
|
| 258 |
# Defense-in-depth org check (HF should already have rejected
|
| 259 |
# non-members upstream because hf_oauth_authorized_org is set).
|
| 260 |
org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
|
| 261 |
if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
|
| 262 |
+
log.warning("[oauth %s] not_in_org user=%s orgs=%s", rid, username, sorted(org_names))
|
| 263 |
return RedirectResponse("/?login_error=not_in_org")
|
| 264 |
|
| 265 |
request.session["user"] = username
|
|
|
|
| 269 |
request.session["access_token"] = access_token
|
| 270 |
request.session.pop("oauth_state", None)
|
| 271 |
next_url = request.session.pop("oauth_next", "/")
|
| 272 |
+
log.info("[oauth %s] success user=%s", rid, username)
|
| 273 |
return RedirectResponse(next_url if next_url.startswith("/") else "/")
|
| 274 |
except Exception as e:
|
| 275 |
+
log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
|
| 276 |
return RedirectResponse("/?login_error=exception")
|
| 277 |
|
| 278 |
|
|
|
|
| 349 |
return [r for r in results if r is not None]
|
| 350 |
|
| 351 |
|
| 352 |
+
# ──────────────────────────────────────────────────────────────
|
| 353 |
+
# Listing cache
|
| 354 |
+
#
|
| 355 |
+
# Each /api/messages call fans out ~100+ HTTP GETs to the Hub, which costs
|
| 356 |
+
# ~10s on a cold CDN cache. The frontend polls every 30s and multiple users
|
| 357 |
+
# may be open at once, so we put a short in-process TTL cache in front of
|
| 358 |
+
# every listing endpoint. Single-flight via per-prefix asyncio.Lock prevents
|
| 359 |
+
# the thundering herd of concurrent first-time loads from each issuing
|
| 360 |
+
# their own fan-out.
|
| 361 |
+
# ──────────────────────────────────────────────────────────────
|
| 362 |
+
LIST_CACHE_TTL = float(os.environ.get("LIST_CACHE_TTL", "20.0"))
|
| 363 |
+
_list_cache: dict[str, tuple[float, list[dict[str, str]]]] = {}
|
| 364 |
+
_list_locks: dict[str, asyncio.Lock] = {}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
async def _cached_list_md(prefix: str) -> list[dict[str, str]]:
|
| 368 |
+
if LOCAL_BUCKET_DIR:
|
| 369 |
+
# Filesystem reads are instant; no cache needed.
|
| 370 |
+
return _list_md_local(prefix)
|
| 371 |
+
now = time.monotonic()
|
| 372 |
+
cached = _list_cache.get(prefix)
|
| 373 |
+
if cached and (now - cached[0]) < LIST_CACHE_TTL:
|
| 374 |
+
return cached[1]
|
| 375 |
+
lock = _list_locks.setdefault(prefix, asyncio.Lock())
|
| 376 |
+
async with lock:
|
| 377 |
+
# Re-check under the lock — another coroutine may have refreshed
|
| 378 |
+
# the cache while we were waiting.
|
| 379 |
+
cached = _list_cache.get(prefix)
|
| 380 |
+
if cached and (time.monotonic() - cached[0]) < LIST_CACHE_TTL:
|
| 381 |
+
return cached[1]
|
| 382 |
+
items = await _list_md_hub(prefix)
|
| 383 |
+
_list_cache[prefix] = (time.monotonic(), items)
|
| 384 |
+
return items
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _invalidate_list_cache(prefix: str) -> None:
|
| 388 |
+
_list_cache.pop(prefix, None)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
# ──────────────────────────────────────────────────────────────
|
| 392 |
# /api/messages and /api/results
|
| 393 |
# ──────────────────────────────────────────────────────────────
|
| 394 |
@app.get("/api/messages")
|
| 395 |
async def messages() -> dict[str, Any]:
|
| 396 |
+
items = await _cached_list_md(PREFIX)
|
| 397 |
return {"items": items, "count": len(items)}
|
| 398 |
|
| 399 |
|
| 400 |
@app.get("/api/results")
|
| 401 |
async def results() -> dict[str, Any]:
|
| 402 |
+
items = await _cached_list_md(RESULTS_PREFIX)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
return {"items": items, "count": len(items)}
|
| 404 |
|
| 405 |
|
| 406 |
@app.get("/api/agents")
|
| 407 |
async def agents() -> dict[str, Any]:
|
| 408 |
+
items = await _cached_list_md(AGENTS_PREFIX)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
return {"items": items, "count": len(items)}
|
| 410 |
|
| 411 |
|
|
|
|
| 499 |
except Exception as e:
|
| 500 |
log.warning("Hub message write failed: %s", e)
|
| 501 |
raise HTTPException(502, "Could not write message to the bucket.") from e
|
| 502 |
+
# Bust the cache so other users see this message on their next poll
|
| 503 |
+
# rather than waiting for the TTL.
|
| 504 |
+
_invalidate_list_cache(PREFIX)
|
| 505 |
return {"item": {"filename": filename, "content": content}}
|
| 506 |
|
| 507 |
|
static/index.html
CHANGED
|
@@ -1685,6 +1685,32 @@ refreshBtn.addEventListener('click', async () => {
|
|
| 1685 |
let postingMessage = false;
|
| 1686 |
let me = { logged_in: false }; // populated by /api/me on init
|
| 1687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1688 |
function setComposerStatus(html = '', isError = false) {
|
| 1689 |
composerStatus.innerHTML = html;
|
| 1690 |
composerStatus.classList.toggle('error', isError);
|
|
@@ -1698,7 +1724,16 @@ function syncComposerState() {
|
|
| 1698 |
sendBtn.classList.add('login');
|
| 1699 |
sendBtn.textContent = 'Log in to post a message';
|
| 1700 |
humanMessageInput.disabled = true;
|
| 1701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1702 |
return;
|
| 1703 |
}
|
| 1704 |
sendBtn.classList.remove('login');
|
|
@@ -1724,11 +1759,33 @@ async function refreshMe() {
|
|
| 1724 |
humanMessageInput.addEventListener('input', syncComposerState);
|
| 1725 |
clearQuoteBtn.addEventListener('click', clearPendingQuote);
|
| 1726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
messageComposer.addEventListener('submit', async e => {
|
| 1728 |
e.preventDefault();
|
| 1729 |
if (!me.logged_in) {
|
| 1730 |
// Treat the button as a login CTA when logged out.
|
| 1731 |
-
|
|
|
|
| 1732 |
return;
|
| 1733 |
}
|
| 1734 |
const body = humanMessageInput.value.trim();
|
|
|
|
| 1685 |
let postingMessage = false;
|
| 1686 |
let me = { logged_in: false }; // populated by /api/me on init
|
| 1687 |
|
| 1688 |
+
// Surface OAuth callback errors. /auth/callback redirects to /?login_error=X
|
| 1689 |
+
// when something fails. We pull it out on boot, clean the URL, and show it
|
| 1690 |
+
// in the composer status until the user attempts another login.
|
| 1691 |
+
const LOGIN_ERROR_HINTS = {
|
| 1692 |
+
bad_state: 'session cookie was lost between /login and /auth/callback (often Safari/iframe third-party cookie blocking)',
|
| 1693 |
+
token_exchange: 'HF rejected the OAuth code exchange',
|
| 1694 |
+
no_token: 'HF returned no access_token',
|
| 1695 |
+
whoami: 'could not fetch your HF profile after login',
|
| 1696 |
+
no_username: 'HF profile had no username',
|
| 1697 |
+
not_in_org: 'your account is not a member of ml-intern-explorers',
|
| 1698 |
+
exception: 'unexpected server error during login',
|
| 1699 |
+
server_unconfigured: 'OAuth is not configured on this Space',
|
| 1700 |
+
access_denied: 'you cancelled the authorization screen',
|
| 1701 |
+
};
|
| 1702 |
+
let lastLoginError = '';
|
| 1703 |
+
(() => {
|
| 1704 |
+
const params = new URLSearchParams(window.location.search);
|
| 1705 |
+
const err = params.get('login_error');
|
| 1706 |
+
if (err) {
|
| 1707 |
+
lastLoginError = err;
|
| 1708 |
+
params.delete('login_error');
|
| 1709 |
+
const qs = params.toString();
|
| 1710 |
+
history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
|
| 1711 |
+
}
|
| 1712 |
+
})();
|
| 1713 |
+
|
| 1714 |
function setComposerStatus(html = '', isError = false) {
|
| 1715 |
composerStatus.innerHTML = html;
|
| 1716 |
composerStatus.classList.toggle('error', isError);
|
|
|
|
| 1724 |
sendBtn.classList.add('login');
|
| 1725 |
sendBtn.textContent = 'Log in to post a message';
|
| 1726 |
humanMessageInput.disabled = true;
|
| 1727 |
+
if (lastLoginError) {
|
| 1728 |
+
const hint = LOGIN_ERROR_HINTS[lastLoginError] || lastLoginError;
|
| 1729 |
+
setComposerStatus(
|
| 1730 |
+
`<strong>Login failed:</strong> ${escapeHtml(hint)}. ` +
|
| 1731 |
+
`Try again, or open the dashboard directly at <a href="${window.location.origin}" target="_top">${escapeHtml(window.location.host)}</a>.`,
|
| 1732 |
+
true,
|
| 1733 |
+
);
|
| 1734 |
+
} else {
|
| 1735 |
+
setComposerStatus('Sign in with Hugging Face — only members of <strong>ml-intern-explorers</strong> can post.');
|
| 1736 |
+
}
|
| 1737 |
return;
|
| 1738 |
}
|
| 1739 |
sendBtn.classList.remove('login');
|
|
|
|
| 1759 |
humanMessageInput.addEventListener('input', syncComposerState);
|
| 1760 |
clearQuoteBtn.addEventListener('click', clearPendingQuote);
|
| 1761 |
|
| 1762 |
+
// When the dashboard runs inside the huggingface.co/spaces/... iframe, the
|
| 1763 |
+
// Space cookies are "third-party". Modern browsers (Safari ITP especially)
|
| 1764 |
+
// drop those cookies, breaking session-based auth. Navigating the *top*
|
| 1765 |
+
// frame to /login means the entire OAuth round-trip happens at *.hf.space
|
| 1766 |
+
// as a first-party context, so the session cookie sticks for everyone.
|
| 1767 |
+
function startLogin() {
|
| 1768 |
+
const loginUrl = window.location.origin + '/login';
|
| 1769 |
+
if (window.self !== window.top) {
|
| 1770 |
+
// Cross-origin parents allow child frames to *write* top.location.href
|
| 1771 |
+
// (the read is what's blocked), so this works even from inside HF's
|
| 1772 |
+
// iframe. After OAuth, the user lands at *.hf.space top-level.
|
| 1773 |
+
try {
|
| 1774 |
+
window.top.location.href = loginUrl;
|
| 1775 |
+
return;
|
| 1776 |
+
} catch {
|
| 1777 |
+
// Fall through to same-frame nav if the parent has unusual policies.
|
| 1778 |
+
}
|
| 1779 |
+
}
|
| 1780 |
+
window.location.href = loginUrl;
|
| 1781 |
+
}
|
| 1782 |
+
|
| 1783 |
messageComposer.addEventListener('submit', async e => {
|
| 1784 |
e.preventDefault();
|
| 1785 |
if (!me.logged_in) {
|
| 1786 |
// Treat the button as a login CTA when logged out.
|
| 1787 |
+
lastLoginError = ''; // user is retrying; clear any stale banner
|
| 1788 |
+
startLogin();
|
| 1789 |
return;
|
| 1790 |
}
|
| 1791 |
const body = humanMessageInput.value.trim();
|