ollama / app /browser.py
charlielin0121's picture
Upload 19 files
1f40f20 verified
Raw
History Blame Contribute Delete
47.1 kB
import asyncio
import base64
import gzip
import json
import re
import time
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from playwright.async_api import (
Browser,
BrowserContext,
Error as PlaywrightError,
Page,
Playwright,
TimeoutError as PlaywrightTimeoutError,
async_playwright,
)
from .config import Settings
class ArenaBridgeError(RuntimeError):
def __init__(self, message: str, *, code: str, status_code: int = 502) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
class ArenaPlaywrightBridge:
"""Drive Arena's normal web UI with Playwright and expose text results to FastAPI."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._playwright: Playwright | None = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._semaphore = asyncio.Semaphore(settings.max_concurrency)
self._model_lock = asyncio.Lock()
self._models: list[str] = []
self._models_cached_at = 0.0
self._storage_state: dict | None = self._decode_storage_state(
settings.arena_storage_state_b64
)
@property
def ready(self) -> bool:
return bool(self._browser and self._browser.is_connected() and self._context)
@staticmethod
def _decode_storage_state(encoded: str) -> dict | None:
if not encoded.strip():
return None
try:
raw = base64.b64decode(encoded.strip(), validate=True)
if raw.startswith(b"\x1f\x8b"):
raw = gzip.decompress(raw)
state = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise RuntimeError("ARENA_STORAGE_STATE_B64 is not valid base64 JSON") from exc
if not isinstance(state, dict) or not isinstance(state.get("cookies", []), list):
raise RuntimeError("ARENA_STORAGE_STATE_B64 does not contain Playwright storage_state")
return state
def _storage_state_without_auth_cookie(self) -> dict | None:
"""Keep account-owner Local Storage consent while forcing a fresh Arena login."""
if not self._storage_state:
return None
return {
**self._storage_state,
"cookies": [
cookie
for cookie in self._storage_state.get("cookies", [])
if not str(cookie.get("name") or "").startswith("arena-auth-prod-v1")
],
}
async def start(self) -> None:
if not self.settings.bridge_api_key:
raise RuntimeError("BRIDGE_API_KEY must be configured")
has_cookie = bool(self.settings.arena_auth_token)
has_storage_state = bool(self._storage_state)
has_credentials = bool(self.settings.arena_email and self.settings.arena_password)
if not has_cookie and not has_storage_state and not has_credentials:
raise RuntimeError(
"Configure ARENA_STORAGE_STATE_B64, ARENA_AUTH_TOKEN, or both "
"ARENA_EMAIL and ARENA_PASSWORD"
)
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
args=["--disable-dev-shm-usage"],
)
try:
if has_storage_state and has_credentials:
# Re-login with a fresh Cookie while retaining the account owner's
# manually established Local Storage/consent state.
await self.login_with_credentials()
elif has_storage_state:
self._context = await self._create_context(storage_state=self._storage_state)
if has_cookie and not await self._read_auth_cookie(self._context):
await self._add_auth_cookie(self._context, self.settings.arena_auth_token)
elif has_credentials:
await self.login_with_credentials()
else:
self._context = await self._create_context()
await self._add_auth_cookie(self._context, self.settings.arena_auth_token)
except Exception:
await self.stop()
raise
async def _create_context(self, *, storage_state: dict | None = None) -> BrowserContext:
if not self._browser or not self._browser.is_connected():
raise ArenaBridgeError(
"Chromium is not available", code="browser_not_ready", status_code=503
)
kwargs = {
"viewport": {
"width": self.settings.viewport_width,
"height": self.settings.viewport_height,
},
"device_scale_factor": 1.0,
"locale": "en-US",
}
if storage_state:
kwargs["storage_state"] = storage_state
return await self._browser.new_context(**kwargs)
@staticmethod
async def _add_auth_cookie(context: BrowserContext, token: str) -> None:
# Large auth values may be split into numbered cookie chunks by web frameworks.
chunk_size = 3500
values = [token[index : index + chunk_size] for index in range(0, len(token), chunk_size)]
cookies = []
for index, value in enumerate(values):
name = "arena-auth-prod-v1" if len(values) == 1 else f"arena-auth-prod-v1.{index}"
cookies.append(
{
"name": name,
"value": value,
"url": "https://arena.ai/",
"httpOnly": True,
"secure": True,
"sameSite": "Lax",
}
)
if cookies:
await context.add_cookies(cookies)
@staticmethod
async def _read_auth_cookie(context: BrowserContext) -> str:
cookies = await context.cookies(["https://arena.ai/"])
for cookie in cookies:
if cookie.get("name") == "arena-auth-prod-v1" and cookie.get("value"):
return str(cookie["value"])
chunks: list[tuple[int, str]] = []
for cookie in cookies:
match = re.fullmatch(r"arena-auth-prod-v1\.(\d+)", str(cookie.get("name") or ""))
if match and cookie.get("value"):
chunks.append((int(match.group(1)), str(cookie["value"])))
return "".join(value for _, value in sorted(chunks))
@staticmethod
async def _first_visible(scope, selector: str):
candidates = scope.locator(selector)
for index in range(await candidates.count()):
candidate = candidates.nth(index)
if await candidate.is_visible():
return candidate
return None
@classmethod
async def _button_by_accessible_names(cls, scope, names: list[str]):
"""Prefer a semantically named button instead of an unrelated submit icon."""
for name in names:
candidates = scope.get_by_role(
"button", name=re.compile(rf"^\s*{re.escape(name)}\s*$", re.IGNORECASE)
)
for index in range(await candidates.count()):
candidate = candidates.nth(index)
if await candidate.is_visible():
return candidate
return None
@staticmethod
async def _scope_near_input(page: Page, input_locator):
"""Use the input's form/dialog before ever searching the entire Arena page."""
form = input_locator.locator("xpath=ancestor::form[1]")
if await form.count():
return form
dialog = input_locator.locator("xpath=ancestor::*[@role='dialog'][1]")
if await dialog.count():
return dialog
# Some React auth widgets do not use <form> or role="dialog". Walk from
# the field outward and choose the first local container with a visible
# button instead of falling back to an unrelated submit icon on the page.
for depth in range(1, 9):
ancestor = input_locator.locator(f"xpath=ancestor::*[{depth}]")
if not await ancestor.count():
continue
buttons = ancestor.locator(
'button[type="submit"], button:has-text("Continue"), '
'button:has-text("Next"), button:has-text("Sign in"), '
'button:has-text("Log in"), [role="button"]:has-text("Continue")'
)
for index in range(await buttons.count()):
if await buttons.nth(index).is_visible():
return ancestor
return page
@staticmethod
async def _safe_button_label(button) -> str:
"""Return only non-sensitive visible/accessibility metadata for diagnostics."""
try:
text = " ".join((await button.inner_text()).split())
if text:
return text[:80]
for attribute in ("aria-label", "title", "name", "type"):
value = str(await button.get_attribute(attribute) or "").strip()
if value:
return value[:80]
except Exception:
pass
return ""
async def login_with_credentials(self) -> str:
"""Use Arena's normal non-OAuth email/password form and capture its auth Cookie."""
if not self.settings.arena_email or not self.settings.arena_password:
raise ArenaBridgeError(
"ARENA_EMAIL and ARENA_PASSWORD are required for automatic login",
code="arena_credentials_missing",
status_code=503,
)
new_context = await self._create_context(
storage_state=self._storage_state_without_auth_cookie()
)
page = await new_context.new_page()
page.set_default_timeout(self.settings.navigation_timeout_ms)
deadline = time.monotonic() + self.settings.login_timeout_seconds
stage = "open_arena"
try:
# "commit" is more reliable than waiting for every third-party script
# on a login page. Individual fields are waited for below.
await page.goto(
self.settings.arena_url,
wait_until="commit",
timeout=self.settings.navigation_timeout_ms,
)
stage = "wait_for_page_body"
await page.locator("body").wait_for(state="attached", timeout=30_000)
stage = "inspect_login_page"
await self._raise_if_blocked(page)
email_input = await self._first_visible(page, self.settings.email_selector)
if email_input is None:
login_control = await self._first_visible(page, self.settings.login_button_selector)
if login_control is None:
sidebar_control = await self._first_visible(
page, self.settings.sidebar_open_selector
)
if sidebar_control is not None:
stage = "open_sidebar"
await sidebar_control.click()
await asyncio.sleep(0.75)
login_control = await self._first_visible(
page, self.settings.login_button_selector
)
if login_control is None:
raise ArenaBridgeError(
"Arena login control was not found. Update ARENA_LOGIN_BUTTON_SELECTOR if "
"the third-party page changed.",
code="arena_login_control_not_found",
status_code=503,
)
stage = "click_login_control"
await login_control.click()
stage = "wait_for_email_field"
while time.monotonic() < deadline:
await self._raise_if_blocked(page)
email_input = await self._first_visible(page, self.settings.email_selector)
if email_input is not None:
break
await asyncio.sleep(0.25)
if email_input is None:
raise ArenaBridgeError(
"Arena email field did not appear",
code="arena_email_field_not_found",
status_code=503,
)
stage = "fill_email"
# Some React auth forms enable their Continue button only after real
# keyboard/input/blur events, not after a single value assignment.
await email_input.fill("")
await email_input.press_sequentially(
self.settings.arena_email.strip(), delay=30
)
await email_input.press("Tab")
await asyncio.sleep(0.5)
stage = "find_password_field"
password_input = await self._first_visible(page, self.settings.password_selector)
if password_input is None:
# Also supports a two-step email -> password form. Prefer the submit
# button in the email field's own form so unrelated page buttons are
# never clicked.
email_scope = await self._scope_near_input(page, email_input)
continue_button = await self._button_by_accessible_names(
email_scope,
["Continue with email", "Continue", "Next", "Sign in", "Log in"],
)
if continue_button is None:
continue_button = await self._first_visible(
email_scope, self.settings.login_submit_selector
)
if continue_button is not None:
stage = "wait_for_email_submit_enabled"
enabled_deadline = min(deadline, time.monotonic() + 15)
while time.monotonic() < enabled_deadline:
if await continue_button.is_enabled():
break
await asyncio.sleep(0.25)
if not await continue_button.is_enabled():
disabled_label = await self._safe_button_label(continue_button)
# Report only field metadata, never values. This reveals whether
# the form expects a checkbox/name/etc. without exposing credentials.
field_descriptions: list[str] = []
diagnostic_scope = email_scope
required_fields = diagnostic_scope.locator(
'input[required], select[required], textarea[required]'
)
for field_index in range(min(await required_fields.count(), 12)):
field = required_fields.nth(field_index)
if not await field.is_visible():
continue
field_descriptions.append(
"/".join(
part
for part in [
str(await field.get_attribute("type") or "field"),
str(await field.get_attribute("name") or "unnamed"),
str(await field.get_attribute("aria-label") or "no-label"),
]
if part
)
)
safe_fields = ", ".join(field_descriptions) or "none detected"
button_descriptions: list[str] = []
visible_buttons = diagnostic_scope.locator('button, [role="button"]')
for button_index in range(min(await visible_buttons.count(), 12)):
candidate = visible_buttons.nth(button_index)
if not await candidate.is_visible():
continue
label = await self._safe_button_label(candidate)
enabled = await candidate.is_enabled()
button_descriptions.append(
f"{label or '<unlabeled>'}({'enabled' if enabled else 'disabled'})"
)
safe_buttons = ", ".join(button_descriptions) or "none detected"
print(
"Arena email-step button remained disabled: "
f"label={disabled_label!r}, visible required fields={safe_fields}, "
f"visible buttons={safe_buttons}",
flush=True,
)
raise ArenaBridgeError(
"Arena's email-step submit button stayed disabled after normal keyboard "
"entry and blur events. The form may require another visible field or "
f"account-owner action. Required field metadata: {safe_fields}",
code="arena_email_submit_disabled",
status_code=409,
)
# The label and enabled state are safe diagnostics; credentials are never logged.
button_label = await self._safe_button_label(continue_button)
print(
f"Arena email-step submit control: label={button_label!r}, enabled=True",
flush=True,
)
stage = "submit_email_step"
try:
# Keep this action timeout short. Some auth pages start a navigation that
# never reaches Playwright's normal readiness condition even though the
# click itself succeeded.
await continue_button.click(timeout=15_000, no_wait_after=True)
except PlaywrightTimeoutError:
password_after_click = await self._first_visible(
page, self.settings.password_selector
)
if password_after_click is None:
stage = "submit_email_step_enter_fallback"
try:
if await email_input.is_visible():
await email_input.press("Enter", timeout=15_000)
except PlaywrightError as exc:
raise ArenaBridgeError(
"Arena's email step could not be submitted by clicking the "
"button or pressing Enter.",
code="arena_email_submit_failed",
status_code=503,
) from exc
stage = "wait_for_password_field"
while time.monotonic() < deadline:
await self._raise_if_blocked(page)
password_input = await self._first_visible(
page, self.settings.password_selector
)
if password_input is not None:
break
await asyncio.sleep(0.25)
if password_input is None:
raise ArenaBridgeError(
"Arena did not present a password field. The selected account may use a magic "
"link, OTP, Google OAuth, or a changed login flow that requires user interaction.",
code="arena_password_field_not_found",
status_code=409,
)
stage = "fill_password"
await password_input.fill(self.settings.arena_password)
stage = "find_login_submit"
password_scope = await self._scope_near_input(page, password_input)
submit = await self._button_by_accessible_names(
password_scope,
["Sign in", "Log in", "Continue", "Submit"],
)
if submit is None:
submit = await self._first_visible(
password_scope, self.settings.login_submit_selector
)
if submit is None:
raise ArenaBridgeError(
"Arena login submit button was not found",
code="arena_login_submit_not_found",
status_code=503,
)
stage = "submit_credentials"
try:
await submit.click(timeout=15_000, no_wait_after=True)
except PlaywrightTimeoutError:
token_after_click = await self._read_auth_cookie(new_context)
if not token_after_click:
stage = "submit_credentials_enter_fallback"
try:
if await password_input.is_visible():
await password_input.press("Enter", timeout=15_000)
except PlaywrightError as exc:
raise ArenaBridgeError(
"Arena's credential form could not be submitted by clicking the button "
"or pressing Enter.",
code="arena_credentials_submit_failed",
status_code=503,
) from exc
stage = "wait_for_auth_cookie"
token = ""
while time.monotonic() < deadline:
await self._raise_if_blocked(page)
token = await self._read_auth_cookie(new_context)
if token:
break
await asyncio.sleep(0.5)
if not token:
raise ArenaBridgeError(
"Arena login did not create arena-auth-prod-v1 before timeout. Check the "
"credentials and whether the account requires MFA, OTP, email confirmation, "
"or interactive verification.",
code="arena_login_failed",
status_code=401,
)
old_context = self._context
self._context = new_context
self.settings.arena_auth_token = token
self._storage_state = await new_context.storage_state()
self._models = []
self._models_cached_at = 0.0
if old_context:
async def close_old_context_later() -> None:
await asyncio.sleep(60)
try:
await old_context.close()
except Exception:
pass
asyncio.create_task(close_old_context_later())
return token
except ArenaBridgeError:
await new_context.close()
raise
except PlaywrightTimeoutError as exc:
await new_context.close()
raise ArenaBridgeError(
f"Arena login timed out during stage '{stage}'. The page may have changed, "
"loaded too slowly, or displayed an interaction that requires the account owner.",
code=f"arena_login_timeout_{stage}",
status_code=504,
) from exc
except PlaywrightError as exc:
await new_context.close()
raise ArenaBridgeError(
f"Playwright failed during Arena login stage '{stage}': {type(exc).__name__}",
code=f"arena_login_playwright_error_{stage}",
status_code=503,
) from exc
except Exception:
await new_context.close()
raise
finally:
if not page.is_closed():
await page.close()
async def refresh_cookie_with_current_session(self) -> str:
"""Reload Arena and capture a Cookie refreshed by the site's own session logic."""
if not self._context:
raise ArenaBridgeError(
"No Arena browser context is active", code="browser_not_ready", status_code=503
)
page = await self._context.new_page()
try:
await page.goto(
self.settings.arena_url,
wait_until="domcontentloaded",
timeout=self.settings.navigation_timeout_ms,
)
await self._raise_if_blocked(page)
await asyncio.sleep(5)
token = await self._read_auth_cookie(self._context)
if not token:
raise ArenaBridgeError(
"Arena did not provide an updated authentication Cookie",
code="arena_cookie_refresh_failed",
status_code=503,
)
self.settings.arena_auth_token = token
self._storage_state = await self._context.storage_state()
return token
finally:
await page.close()
async def stop(self) -> None:
if self._context:
await self._context.close()
if self._browser:
await self._browser.close()
if self._playwright:
await self._playwright.stop()
self._context = None
self._browser = None
self._playwright = None
async def _new_page(self) -> Page:
if not self.ready or not self._context:
raise ArenaBridgeError(
"Playwright browser is not ready", code="browser_not_ready", status_code=503
)
page = await self._context.new_page()
page.set_default_timeout(self.settings.navigation_timeout_ms)
try:
await page.goto(
self.settings.arena_url,
wait_until="domcontentloaded",
timeout=self.settings.navigation_timeout_ms,
)
await self._raise_if_blocked(page)
await page.locator(self.settings.input_selector).first.wait_for(state="visible")
return page
except ArenaBridgeError:
await page.close()
raise
except PlaywrightTimeoutError as exc:
current_url = page.url
await self._save_failure_artifacts(page)
await page.close()
raise ArenaBridgeError(
"Arena's chat input did not become available. The authentication Cookie may be "
"expired/invalid, or the third-party page layout may have changed. "
f"Current page: {current_url}",
code="arena_page_not_ready",
status_code=503,
) from exc
except PlaywrightError as exc:
await self._save_failure_artifacts(page)
await page.close()
raise ArenaBridgeError(
"Playwright could not load Arena's chat page",
code="arena_navigation_error",
status_code=503,
) from exc
async def _raise_if_blocked(self, page: Page) -> None:
title = (await page.title()).strip().lower()
if "just a moment" in title:
raise ArenaBridgeError(
"Arena displayed an interactive security checkpoint. Complete it on the official "
"site with your own browser, then refresh ARENA_AUTH_TOKEN.",
code="interactive_verification_required",
status_code=503,
)
dialogs = page.locator('[role="dialog"]:visible')
for index in range(await dialogs.count()):
text = (await dialogs.nth(index).inner_text()).strip()
lowered = text.lower()
if "terms of use & privacy policy" in lowered and "agree" in lowered:
raise ArenaBridgeError(
"Arena requires the account owner to review and accept its terms on the official "
"website. This adapter will not accept legal terms automatically.",
code="account_consent_required",
status_code=409,
)
if "recaptcha" in lowered and ("verification" in lowered or "verify" in lowered):
raise ArenaBridgeError(
"Arena requires interactive reCAPTCHA verification. Complete it manually on the "
"official website; this adapter does not solve CAPTCHA challenges.",
code="interactive_verification_required",
status_code=503,
)
verification_text = page.get_by_text(
re.compile(r"recaptcha requires verification", re.IGNORECASE)
)
if await verification_text.count():
for index in range(await verification_text.count()):
if await verification_text.nth(index).is_visible():
raise ArenaBridgeError(
"Arena requires interactive reCAPTCHA verification. Complete it manually on "
"the official website.",
code="interactive_verification_required",
status_code=503,
)
async def _open_model_dialog(self, page: Page):
button = page.locator(f"{self.settings.model_button_selector}:visible").first
if not await button.count():
raise ArenaBridgeError(
"Arena model selector was not found; the third-party page layout may have changed",
code="model_selector_not_found",
)
await button.click()
dialog = page.locator('[role="dialog"]:visible').last
try:
await dialog.wait_for(state="visible")
await dialog.locator('[role="listbox"]').wait_for(state="visible")
except PlaywrightTimeoutError as exc:
raise ArenaBridgeError(
"Arena model dialog did not open", code="model_dialog_not_found"
) from exc
return dialog
@staticmethod
def _option_label(raw_text: str) -> str:
return next((line.strip() for line in raw_text.splitlines() if line.strip()), "")
async def list_models(self, *, force: bool = False) -> list[str]:
now = time.monotonic()
if (
not force
and self._models
and now - self._models_cached_at < self.settings.model_cache_seconds
):
return list(self._models)
async with self._model_lock:
now = time.monotonic()
if (
not force
and self._models
and now - self._models_cached_at < self.settings.model_cache_seconds
):
return list(self._models)
async with self._semaphore:
page = await self._new_page()
try:
dialog = await self._open_model_dialog(page)
options = dialog.locator('[role="option"]')
models: list[str] = []
for index in range(await options.count()):
label = self._option_label(await options.nth(index).inner_text())
if label and label.casefold() not in {item.casefold() for item in models}:
models.append(label)
await page.keyboard.press("Escape")
if not models:
raise ArenaBridgeError(
"Arena returned an empty model selector", code="empty_model_list"
)
self._models = models
self._models_cached_at = time.monotonic()
return list(models)
finally:
await page.close()
async def _select_model(self, page: Page, requested_model: str) -> str:
button = page.locator(f"{self.settings.model_button_selector}:visible").first
if not await button.count():
raise ArenaBridgeError(
"Arena model selector was not found", code="model_selector_not_found"
)
current = self._option_label(await button.inner_text())
if current.casefold() == requested_model.casefold():
return current
dialog = await self._open_model_dialog(page)
search = dialog.locator('[role="combobox"]').last
if await search.count():
await search.fill(requested_model)
options = dialog.locator('[role="option"]')
matched = None
available: list[str] = []
for index in range(await options.count()):
option = options.nth(index)
label = self._option_label(await option.inner_text())
if label:
available.append(label)
if label.casefold() == requested_model.casefold():
matched = option
requested_model = label
break
if matched is None:
await page.keyboard.press("Escape")
raise ArenaBridgeError(
f"Model '{requested_model}' was not found in Arena's current model selector",
code="model_not_found",
status_code=404,
)
await matched.click()
return requested_model
async def _install_response_observer(self, page: Page, prompt: str) -> None:
await page.evaluate(
"""
(prompt) => {
if (window.__arenaBridgeObserver) window.__arenaBridgeObserver.disconnect();
const state = { elements: new Set(), prompt };
window.__arenaBridgeState = state;
const main = document.querySelector('main') || document.body;
const excluded = 'button, textarea, input, form, nav, header, [role="dialog"]';
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
let element = mutation.target.nodeType === Node.TEXT_NODE
? mutation.target.parentElement
: mutation.target;
if (!(element instanceof Element) || !main.contains(element)) continue;
if (element.closest(excluded)) continue;
const block = element.closest('p, pre, blockquote, li, h1, h2, h3, h4, h5, h6') || element;
const text = (block.innerText || block.textContent || '').trim();
// User prompts can be rendered as many smaller DOM blocks. Exclude
// both the complete prompt and sufficiently long fragments of it.
if (!text || text === prompt || text.includes(prompt)) continue;
if (text.length >= 20 && prompt.includes(text)) continue;
state.elements.add(block);
}
});
observer.observe(main, { subtree: true, childList: true, characterData: true });
window.__arenaBridgeObserver = observer;
}
""",
prompt,
)
@staticmethod
def _looks_like_prompt_fragment(text: str, prompt: str) -> bool:
normalized_text = re.sub(r"\s+", " ", text.replace("\u200b", " ")).strip()
normalized_prompt = re.sub(r"\s+", " ", prompt.replace("\u200b", " ")).strip()
if len(normalized_text) < 20:
return False
return normalized_text in normalized_prompt or normalized_prompt in normalized_text
@staticmethod
def _clean_response_text(text: str, prompt: str) -> str:
text = text.replace("\u200b", "").strip()
if prompt and prompt in text:
text = text.rsplit(prompt, 1)[-1].strip()
ignored_lines = {
"Add files",
"Code",
"Image",
"Search",
"Send message",
"Stop generating",
}
lines = [line.rstrip() for line in text.splitlines()]
while lines and (not lines[-1].strip() or lines[-1].strip() in ignored_lines):
lines.pop()
return "\n".join(lines).strip()
async def _extract_response_text(self, page: Page, prompt: str) -> str:
if self.settings.response_selector:
candidates = page.locator(f"{self.settings.response_selector}:visible")
if await candidates.count():
return self._clean_response_text(await candidates.last.inner_text(), prompt)
generic_selectors = (
'[data-message-author-role="assistant"]:visible, '
'[data-role="assistant"]:visible, '
'[data-testid*="assistant" i]:visible, '
'article [class*="markdown" i]:visible, '
'article [class*="prose" i]:visible'
)
generic = page.locator(generic_selectors)
if await generic.count():
raw_text = await generic.last.inner_text()
if not self._looks_like_prompt_fragment(raw_text, prompt):
text = self._clean_response_text(raw_text, prompt)
if text and not self._looks_like_prompt_fragment(text, prompt):
return text
observed = await page.evaluate(
"""
() => {
const state = window.__arenaBridgeState;
if (!state) return '';
const seen = new Set();
const blocks = [];
for (const element of state.elements) {
if (!element?.isConnected) continue;
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') continue;
const text = (element.innerText || element.textContent || '').trim();
if (!text || text === state.prompt || text.includes(state.prompt) || seen.has(text)) continue;
if (text.length >= 20 && state.prompt.includes(text)) continue;
seen.add(text);
blocks.push({ element, text });
}
blocks.sort((a, b) => {
const relation = a.element.compareDocumentPosition(b.element);
return relation & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
return blocks.map(item => item.text).join(String.fromCharCode(10)).trim();
}
"""
)
text = self._clean_response_text(str(observed or ""), prompt)
if text and not self._looks_like_prompt_fragment(text, prompt):
return text
# Last-resort fallback for future DOM changes. A configurable
# ARENA_RESPONSE_SELECTOR is preferred when this fallback is inaccurate.
main_text = await page.locator("main").first.inner_text()
if prompt in main_text:
candidate = self._clean_response_text(main_text.rsplit(prompt, 1)[-1], prompt)
if candidate and not self._looks_like_prompt_fragment(candidate, prompt):
return candidate
return ""
@staticmethod
async def _is_generating(page: Page) -> bool:
selectors = (
'button[aria-label*="stop" i]:visible, '
'button[title*="stop" i]:visible, '
'button[aria-label*="cancel" i]:visible'
)
return bool(await page.locator(selectors).count())
async def _save_failure_artifacts(self, page: Page) -> None:
if not self.settings.save_failure_artifacts:
return
directory = Path(self.settings.artifact_dir)
directory.mkdir(parents=True, exist_ok=True)
artifact_id = uuid.uuid4().hex
try:
await page.screenshot(path=str(directory / f"{artifact_id}.png"), full_page=True)
(directory / f"{artifact_id}.html").write_text(
await page.content(), encoding="utf-8"
)
except Exception:
pass
async def chat(
self,
*,
model: str,
prompt: str,
on_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, str]:
async with self._semaphore:
stage = "open_chat_page"
page = await self._new_page()
try:
stage = "select_model"
selected_model = await self._select_model(page, model)
stage = "fill_prompt"
input_box = page.locator(self.settings.input_selector).first
await input_box.fill(prompt)
# Install once for same-page submissions. Arena currently changes from
# /text/direct to /c/<conversation>, so it is installed again below.
stage = "install_initial_response_observer"
await self._install_response_observer(page, prompt)
stage = "find_send_button"
send = page.locator(f"{self.settings.send_selector}:visible").first
if not await send.count():
raise ArenaBridgeError(
"Arena send button was not found", code="send_button_not_found"
)
initial_url = page.url
stage = "click_send_button"
await send.click(timeout=15_000, no_wait_after=True)
# Sending a first message commonly performs a client-side navigation.
# Waiting briefly and reinstalling the observer prevents
# "execution context was destroyed" errors on the old document.
stage = "wait_for_conversation_navigation"
try:
await page.wait_for_url(
lambda url: str(url) != initial_url,
timeout=10_000,
wait_until="commit",
)
except PlaywrightTimeoutError:
pass
await asyncio.sleep(0.5)
stage = "install_conversation_response_observer"
observer_installed = False
for _ in range(20):
try:
await self._install_response_observer(page, prompt)
observer_installed = True
break
except PlaywrightError:
if page.is_closed():
break
await asyncio.sleep(0.25)
if page.is_closed():
raise ArenaBridgeError(
"Arena closed the conversation page after the message was sent",
code="arena_conversation_page_closed",
status_code=502,
)
if not observer_installed:
raise ArenaBridgeError(
"Arena's conversation document did not become stable after navigation",
code="arena_conversation_navigation_unstable",
status_code=502,
)
started_at = time.monotonic()
stable_since = started_at
transient_error_since: float | None = None
previous = ""
while True:
now = time.monotonic()
if now - started_at > self.settings.request_timeout_seconds:
raise ArenaBridgeError(
"Timed out while waiting for Arena's web UI response",
code="upstream_timeout",
status_code=504,
)
try:
stage = "check_post_send_blockers"
await self._raise_if_blocked(page)
stage = "extract_assistant_response"
current = await self._extract_response_text(page, prompt)
if current != previous:
stable_since = now
if on_delta and current.startswith(previous):
delta = current[len(previous) :]
if delta:
await on_delta(delta)
previous = current
stage = "check_generation_state"
generating = await self._is_generating(page)
transient_error_since = None
except PlaywrightError as exc:
# React/Next.js can briefly replace the execution context while
# establishing the conversation route. Retry transient errors,
# but retain a bounded failure window for real page breakage.
if page.is_closed():
raise ArenaBridgeError(
"Arena closed the conversation page while generating",
code="arena_conversation_page_closed",
status_code=502,
) from exc
if transient_error_since is None:
transient_error_since = now
if now - transient_error_since > 10:
safe_detail = str(exc).splitlines()[0][:240]
raise ArenaBridgeError(
f"Playwright repeatedly failed during chat stage '{stage}': "
f"{safe_detail}",
code=f"playwright_page_error_{stage}",
status_code=502,
) from exc
await asyncio.sleep(0.25)
continue
if (
previous
and not generating
and now - stable_since >= self.settings.response_stable_seconds
):
return previous, selected_model
await asyncio.sleep(0.25)
except ArenaBridgeError:
await self._save_failure_artifacts(page)
raise
except PlaywrightError as exc:
await self._save_failure_artifacts(page)
safe_detail = str(exc).splitlines()[0][:240]
print(
f"Playwright chat failure at stage '{stage}': {type(exc).__name__}: "
f"{safe_detail}",
flush=True,
)
raise ArenaBridgeError(
f"Playwright failed during chat stage '{stage}': {safe_detail}",
code=f"playwright_page_error_{stage}",
status_code=502,
) from exc
finally:
if not page.is_closed():
await page.close()